Claude Code 源码深度讲解 · 任务管理系统

TodoWrite & Tasks 完整运行原理

Claude Code 同时存在两套任务管理系统:轻量的 TodoWrite (V1) 用于单会话内存清单, 持久化的 Tasks (V2) 用于跨会话、跨进程、多 agent 协作。本文从工具定义、 存储模型、锁机制、Hook 系统、UI 渲染到惰性提醒,逐层解构两套系统的实现细节。

01概览:为什么有两套系统

乍看之下 TodoWrite 和 Task* 工具做的是同一件事 —— 维护一个任务清单。但它们的存储位置、生命周期、并发模型、UI 表现、可观察性完全不同,设计目标也不同。

1.1 V1 vs V2 对照

维度V1 · TodoWriteV2 · 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,可阻断完成
关键判定函数(utils/tasks.ts:133)
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 渲染的完整数据流。

任务管理系统数据流 第 1 层 · 模型工具调用 TodoWrite({todos:[...]}) · TaskCreate(subject, description) · TaskUpdate(taskId, status) · TaskList() · TaskGet(taskId) 第 2 层 · Tool 实现 输入校验(Zod) · 权限检查(allow/deny) · 调用业务函数 · 触发 Hook · 转换为 tool_result packages/builtin-tools/src/tools/{TodoWriteTool, TaskCreateTool, TaskUpdateTool, ...}/ 第 3a 层 · 内存存储(V1) AppState.todos[agentId] = TodoList setAppState 触发 Zustand store 更新 订阅者直接 re-render state/AppStateStore.ts 第 3b 层 · 文件存储(V2) ~/.claude/tasks/<listId>/{N.json, .lock, .highwatermark} proper-lockfile 文件锁 + JSON 序列化 notifyTasksUpdated() 进程内信号 utils/tasks.ts 第 4 层 · 跨进程同步(仅 V2) fs.watch 监听目录 · 5s fallback poll · debounce 50ms hooks/useTasksV2.ts (TasksV2Store) 第 5 层 · 惰性提醒(注入到下一轮提示) 附件管线检查 turnsSinceLastTodoWrite ≥ 10 → 注入 todo_reminder / task_reminder 附件 utils/attachments.ts:getTodoReminderAttachments / getTaskReminderAttachments 第 6 层 · UI 渲染 components/TaskListV2.tsx · useTasksV2()
任务管理系统 6 层架构:工具 → 业务函数 → 存储 → (V2 跨进程同步) → 惰性提醒 → 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 } }
}
V1 的"整体替换"含义
每次 TodoWrite 调用都必须传完整的清单。如果模型只想改一项,也得把所有项重新 yield 出来。 这是模型 prompt 里强调的"keep one in_progress at a time"难以执行的原因之一 —— 每次刷新整张表,容易漏改状态。

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 目录结构示意

~/.claude/tasks/ ├── <sessionId-or-teamName>/ ├── 1.json {"id":"1", "subject":"Add login", "status":"completed", "blocks":["3"], ...} ├── 2.json {"id":"2", "subject":"Add tests", "status":"in_progress", ...} ├── 3.json {"id":"3", "subject":"Deploy", "blockedBy":["1","2"], ...} ├── .lock proper-lockfile 文件锁(空文件) └── .highwatermark "5" ← 历史最高 ID,防止删除后 ID 复用 ├── <another-team>/ └── ... └── ... 每个 task 一个 JSON 文件,可被多个 Claude 进程同时打开,通过 .lock 文件协调写入
V2 任务列表的磁盘布局:每个 listId 一个目录,每个 task 一个 JSON 文件

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 多进程是否共享任务列表。

getTaskListId() 调用 1. CLAUDE_CODE_TASK_LIST_ID env var? 是 → 用 env 值 2. teammateContext (in-process teammate) getTeammateContext() ? 是 → 用 leader.teamName 3. CLAUDE_CODE_TEAM_NAME getTeamName() ? 是 → 用 team 名 4. leaderTeamName(由 TeamCreate 设置) leaderTeamName ? 是 → 用 leader 名 5. fallback: getSessionId()
5 级优先级,前 4 项都是为多进程协作设计,session ID 是单机 fallback

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()
}
为什么这么复杂?
Claude Code 支持多 Claude 进程协作(swarm 模式)。它们可能跑在:
  • 同一 Node.js 进程的不同 worker(in-process teammate)
  • 同一机器的不同 tmux 窗口(每个 tmux pane 跑一个 claude)
  • iTerm2 的多个 split pane
它们必须看到同一个任务列表,否则协作不可能。所以 listId 不能用 sessionId(每个进程不同), 必须用一个共享标识 —— 团队名。优先级越高的来源越"靠近 leader"。

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 间隔
  },
}
为什么是 30 次重试?
注释里有数学计算:每个临界区做 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 列表级锁的获取流程

createTask 开始 ensureTaskListLockFile mkdir -p & writeFile(.lock, '', { flag: 'wx' }) lockfile.lock(.lock) 最多 30 次重试,~2.6s 总等 findHighestTaskId max(readdir, readHighWaterMark) writeFile(<id>.json) notifyTasksUpdated() finally: release() 提示:为什么需要 lockfile writeFile 在 Node.js 里 ≈ 多个 syscall。两个进程可能同时 readHighest=5 → 都写 6.json, 后写覆盖前者。lockfile 把整个 "读最高 ID + 写新文件"做成 一个原子临界区。
createTask 的完整加锁流程,关键是"读最高 ID + 写新文件"必须在锁内

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 问题场景

create #1 tasks: [1] create #2 tasks: [1,2] #2.blockedBy=[1] delete #1 tasks: [2] #2.blockedBy=[] create #? 如果复用 ID=1? #2.blockedBy 重新指向新 #1! create #3 正确:用 highwatermark 问题:删除 #1 后,如果按 max(remaining files)+1 来分配,会分到 ID=3。 但如果删除 #2 和 #3 后再分配,会复用 ID=2,导致引用悬挂。
不用 high water mark 的潜在问题:删除任务后 ID 复用导致跨任务引用错位

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 文件

  1. 预检查存在:getTask 一次,不存在直接返回 task_not_found。proper-lockfile 在不存在文件上会抛错,先检查避免异常。
  2. 拿任务级锁:lockfile.lock(taskPath)
  3. 重读任务状态:被锁前可能已被改
  4. 检查 owner:已被别人占 → already_claimed
  5. 检查 status:已 completed → already_resolved
  6. 遍历 listTasks 查 blockedBy:有未完成阻塞者 → blocked
  7. updateTaskUnsafe(owner = me):已经持锁,用 unsafe 版本避免死锁
  8. 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
为什么 busy 检查需要列表锁?
如果只锁目标 task 文件,TOCTOU 漏洞会出现:
  1. Agent A 拿着 task #5 的锁,扫描其他 tasks → 发现自己只拥有 #5,不 busy
  2. 同时 Agent A 在另一进程拿到 task #8 的锁,做同样检查 → 也不 busy
  3. 两个进程都成功 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" }:

  1. createTask 已写入 ~/.claude/tasks/<listId>/N.json
  2. Hook 跑出 exit 2 → blockingError
  3. TaskCreate 看到 blockingErrors.length > 0 → 调用 deleteTask(N) 回滚
  4. throw 错误 → 工具结果是 tool_result(is_error: true, content: hook 错误信息)
  5. 模型读到错误,下一回合改 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 }),
])
三个关键设计
  1. isMeta: true:这条消息不会显示在 UI 给用户看
  2. <system-reminder> 包裹:模型 prompt 里专门指导"反应这种格式但不要提起"
  3. "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,
}
为什么这种"边缘提示"很关键
模型有"完成偏差"(completion bias):写完最后一个 task 后倾向于直接给出总结。 但很多任务实际上没真的完成 —— 测试没跑、构建没过、edge case 没覆盖。 "loop-exit moment"是最容易 skip 验证的瞬间,所以在这个时刻附加一条结构性提醒, 把"模型自我验证"变成"调用专门 verifier agent"。

13.3 TaskUpdate 镜像逻辑

V2 在 TaskUpdate 里有同样的 nudge 实现(TaskUpdateTool.ts:333),触发条件: updates.status === 'completed'allDoneallTasks.length ≥ 3 且没有 verification 任务。

14UI 渲染与文件监听

V2 的 UI 必须感知跨进程的任务变化。TasksV2Store 是一个 React 18 的 useSyncExternalStore 适配器,管理 fs.watch + 进程内信号 + 5s fallback poll。

14.1 三种数据源

TasksV2Store 单例 · 全 hook 实例共享 #tasks · #hidden · #subscriberCount fs.watch 监听任务目录 跨进程信号 debounce 50ms onTasksUpdated 进程内信号 调 Tool 后立即触发 5s fallback poll 仅有未完成任务时 兜底 fs.watch 漏事件 REPL.tsx long-lived subscriber Spinner.tsx mount/unmount 每回合 PromptInputFooterLeftSide TaskListV2 展开面板渲染
TasksV2Store 三源同步 + 多订阅者共享:避免 N 个组件各自起 fs.watch 的开销

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 的工具:

  1. 初始 prompt 里只发工具名 + searchHint,不发 schema
  2. 模型必须先用 ToolSearch 查找到这个工具
  3. 查到后 schema 才会被加入"已解锁"列表,模型才能调用它
为什么任务工具都是 deferred?
TaskCreate / TaskUpdate / TaskList / TaskGet / TodoWrite 加起来的 prompt 大约 4-6k token。 大多数 query 根本不需要任务管理(单步小任务占绝大多数)。 延迟加载这些工具能在不损失功能的前提下,让 90%+ 的 query 节省 4-6k token。

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 流程

~/.claude/tasks/<teamName>/ 1.json (status:in_progress, owner:alice) 2.json (status:pending, owner:undefined) 3.json (blockedBy:[2]) .lock · .highwatermark Leader tmux pane 0 分配任务 · 监控进度 setLeaderTeamName('myteam') TaskCreate · TaskUpdate(owner) Teammate · Alice tmux pane 1 CLAUDE_CODE_TEAM_NAME=myteam claimTask + 完成后 TaskList 找下一个 Teammate · Bob tmux pane 2 空闲 → 等待 mailbox 或 claim Mailbox 通知 writeToMailbox(newOwner, ...) JSON: type=task_assignment 由 TaskUpdate 设 owner 时触发 每个 teammate 的 TasksV2Store fs.watch 监听同一目录 UI 实时刷新 Teammate 死亡处理 unassignTeammateTasks 扫描 owner === dead 清空 owner + status=pending 可被其他 teammate 重新认领
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 切换"

  1. 用户输入
    "给 settings 页面加一个 dark mode 切换,做完跑测试和 build"
  2. 模型识别为复杂任务,调用 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 项。

  3. 模型 TaskUpdate(taskId:"1", status:"in_progress")

    swarm 关闭 → 不自动设 owner。仅状态变化。

    UI 端 spinner 把"思考中"改成 task 1 的 activeForm("Creating dark mode toggle component")。

  4. 模型连续调用 FileEdit / FileWrite 完成 task 1

    这些工具不影响任务状态,只是修改文件。

  5. TaskUpdate(taskId:"1", status:"completed")

    触发 TaskCompleted hook(无配置)→ 不阻断。

    verification nudge 检查:5 个 task 还没全部完成,不触发。

    tool_result content: "Updated task #1 status"

  6. 重复 step 3-5 处理 task 2-5
  7. 关键时刻: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 = true

    tool_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.
  8. 模型读到 nudge,主动调用 Agent 工具启动 verifier

    verifier 子 agent 跑测试、看代码,产出 PASS/PARTIAL/FAIL verdict。

  9. TasksV2Store 自动清理

    所有 task completed → 启动 5s 倒计时 → 时间到 → 调 resetTaskList:

    • 读最高 ID = 5
    • .highwatermark = 5
    • unlink 所有 *.json
    • this.#hidden = true
    • notify() → UI 任务面板淡出

    之后如果用户再发新任务 → 重新 TaskCreate → 从 ID = 6 开始,不复用旧 ID。

关键时间线

用户输入 t=0 5× TaskCreate t=2s UI 展开 task 1 in_progress t=3s task 1 completed t=15s task 2-4 完成 t=2min task 5 完成 t=3min 触发 verifier 5s 后清理 t=3min+5s 面板淡出 用户视角:任务列表自动出现 → 实时勾选 → 完成后 5s 自动消失
端到端时间线:文件系统是真理来源,UI 通过 fs.watch + 进程内信号实时同步

18核心设计原则

从这套实现中可以提炼出 7 条可迁移到其他 agent 系统的设计原则。

  1. 双轨制 V1/V2 共存,而非一刀切迁移

    SDK 用户依赖纯内存清单(简单、无副作用),交互用户需要持久化 + 多进程协作。 通过 isTodoV2Enabled() 在工具注册阶段做排他启用, 模型每个会话只看到一套接口,不会混淆。这是"渐进式架构演进"的好范例。

  2. 文件系统作为多进程的唯一真理来源

    不引入数据库、不依赖 IPC,直接用 ~/.claude/tasks/<listId>/<id>.json + proper-lockfile + fs.watch。这种设计天然兼容 tmux/iTerm2/独立进程等任意运行形态, 只要能访问同一文件系统就能协作。

  3. 双向引用 + 单向真理:权衡一致性与简单性

    blocks/blockedBy 双向冗余,但 blockTask 不是事务。模型只读 blockedBy 来决定能否 claim, 单向残留只影响显示。"哪一端是真理"比"两端绝对一致"更值得设计

  4. High Water Mark:抗 ID 复用

    哪怕全部任务删除,新任务也从历史最大 + 1 开始。这种"单调递增不回头"的策略 让任意外部引用(mailbox、log、git commit message)都能保持稳定。

  5. 惰性提醒 + 强约束:控制模型行为而非命令

    10 turn 没用 task 工具 → 注入 system-reminder;3+ 任务关闭无 verifier → 注入 nudge。 这些都是"行为推荐"而非"硬性中断", 让模型保留判断权但减少漏掉关键步骤的概率。

  6. Hook 阻断必须可回滚

    TaskCreate 已经写文件再调 hook,如果阻断就 deleteTask 回滚 —— 任务从未存在过。 这给用户的 hook 提供了真正的"否决权",而不是"事后补救"。

  7. shouldDefer:工具数量与 prompt 成本的平衡

    5 个任务工具加起来 4-6k token,但 90% query 用不到。把它们标记为 deferred, 需要时由 ToolSearch 唤醒,既保留功能又避免 token 浪费。 这是大型工具集 agent 系统的标准模式

最重要的一条:让任务系统成为"模型的工作记忆"
TodoWrite 和 Tasks 表面上是给用户看进度,实际上更重要的功能是给模型提供结构化的工作记忆。 模型在长会话里容易"忘记接下来要做什么",任务列表本身就是 prompt 的一部分(通过 reminder 注入)。 verification nudge 直接干预完成偏差。这套机制的真正价值在于把"软推荐"做到产品级别: 既不强迫模型,又显著提升任务完成质量。