Claude Code 源码分析第 14 课 · 第 08
第14课

任务系统

⏱ ~45 分钟 📄 Task.ts · 任务/ · utils/task/ · 工具/TaskCreateTool/ · 工具/TaskUpdateTool/ 🔬 高级

🗺️ 什么是任务?

当 Claude Code 运行 bash 命令、生成子代理、启动远程云会话或启动“梦想”内存整合代理时 - 每一个都被跟踪为 task。 任务是 Claude Code 的模型,用于与主要对话一起运行的并发、可观察的工作单元。

核心抽象位于 Task.ts。 系统中的每个任务共享一个共同的基本状态(TaskStateBase)、五种状态的生命周期和单一的kill接口:

// Task.ts — the minimal interface every task type implements
export type Task = {
  name: string
  type: TaskType
  kill(taskId: string, setAppState: SetAppState): Promise<void>
}

// Seven concrete types live in the codebase today
export type TaskType =
  | 'local_bash'        // LocalShellTask
  | 'local_agent'       // LocalAgentTask (+ LocalMainSessionTask)
  | 'remote_agent'      // RemoteAgentTask
  | 'in_process_teammate' // InProcessTeammateTask
  | 'local_workflow'    // LocalWorkflowTask
  | 'monitor_mcp'       // MonitorMcpTask
  | 'dream'             // DreamTask

// Shared fields every task state carries
export type TaskStateBase = {
  id: string          // e.g., "b3f7a2c1..." (prefix encodes type)
  type: TaskType
  status: TaskStatus   // pending | running | completed | failed | killed
  description: string
  toolUseId?: string   // links back to the tool_use that spawned this task
  startTime: number
  endTime?: number
  outputFile: string   // absolute path to the task output file on disk
  outputOffset: number // byte offset: how much output has been consumed
  notified: boolean    // prevents double-notification (atomically set)
}
💡
任务 ID 对其类型进行编码

每种任务类型都有一个单字母前缀: b=bash, a=代理, r=远程, t=队友, w=工作流程, m=监视器, d=梦想, s=主要会议。 其余 8 个字符是加密随机的(36^8 ≈ 2.8 万亿个组合)。 这可以防止沙盒环境中基于符号链接的路径遍历攻击。

🔄 生命周期

每个任务都会经历五个状态。 其中只有三个是终端(任务一旦到达这些就不会进一步转换):

pending
running
completed
failed
killed
stateDiagram-v2 direction LR [*] --> pending : registerTask() pending --> running : task starts running --> completed : exit code 0 / agent success running --> failed : exit code != 0 / agent error running --> killed : kill() called completed --> [*] : evicted after notified=true failed --> [*] : evicted after notified=true killed --> [*] : evicted after notified=true

通知标志:防止重复投递

The notified 场上 TaskStateBase 是一个单向锁存器。 当任务完成时(任何终端状态),系统自动设置 notified: true 并将单个 XML 通知排队到模型中。 因为这是由比较和设置保护的状态机转换,所以即使两个异步路径(任务的结果处理程序和终止调用)竞相完成,也只会触发一个通知:

// Pattern used by every task type (e.g. LocalAgentTask.ts)
let shouldEnqueue = false
updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => {
  if (task.notified) {
    return task           // already notified — no-op
  }
  shouldEnqueue = true
  return { ...task, notified: true }
})
if (!shouldEnqueue) return   // another path already sent the notification
// ... enqueue XML notification ...

逐出:通知后释放内存

一旦任务终止并得到通知,它就有资格从 AppState.tasks。 有两种机制可以处理这个问题:惰性 GC 传入 generateTaskAttachments() (在每个轮询标记上运行)和一个名为的急切路径 evictTerminalTask() 由任务自己的完成处理程序调用。 这 local_agent type 添加了第三个:可配置的宽限期(evictAfter)对于协调器任务面板中保留的任务,以便 UI 可以在任务完成后短暂显示它们。

📦 七种任务类型

LocalShellTask local_bash

前台或后台 bash 命令。 Stdout/stderr 通过操作系统文件描述符直接访问文件——根本没有 JS 缓冲。 通过每 1 秒轮询一次文件尾部来提取进度。

LocalAgentTask local_agent

在同一 Node.js 进程中运行的后台 Claude 代理。 用途 runAgent()。 跟踪面板 UI 的令牌计数、工具使用计数和近期活动。 还涵盖 LocalMainSessionTask (主会话通过 Ctrl+B 作为后台)。

RemoteAgentTask remote_agent

在云环境中运行的会话(传送)。 通过 Teleport API 进行轮询。 亚型包括 ultraplan, ultrareview, autofix-pr, 和 background-pr。 有一个 ultraplanPhase 计划审批流程字段。

InProcessTeammateTask in_process_teammate

具有 AsyncLocalStorage 隔离的进程内运行的队友代理。 具有身份 (agentName@teamName),支持计划模式批准、邮箱消息传递以及不同于运行/挂起的空闲/活动生命周期。

LocalWorkflowTask local_workflow

多步骤后台工作流程。 发出 task_progress SDK 结构化事件 SdkWorkflowProgress[] 因此外部消费者可以跟踪每个步骤的完成情况。

MonitorMcpTask monitor_mcp

源自 MCP 的监控进程。 在页脚中显示为不同的药丸(“N 个监视器”),而不是折叠到外壳计数中。 启用由 MONITOR_TOOL 功能标志。

DreamTask dream

自动梦记忆巩固子代理。 仅 UI — 没有面向模型的通知路径。 具有四阶段结构(定向/聚集/巩固/修剪),表示为两个阶段: starting and updating。 在终止时,回滚整合锁,以便下一个会话可以重试。

🔍
LocalMainSessionTask 不是一个单独的 TaskType

它重复利用 local_agent 状态与 agentType: 'main-session'。 识别是通过 isMainSessionTask() 谓词。 这使得主会话可以共享整个代理基础设施(终止路径、逐出逻辑、面板显示),而无需重复代码。

🔬 深潜

LocalShellTask​​:前台与后台,停顿看门狗

两条生成路径:背景和前景

大多数 bash 任务都是通过以下方式直接作为后台任务生成的 spawnShellTask() with isBackgrounded: true。 但是,当命令运行足够长的时间以在 TUI 中显示“背景提示”时,它会通过 registerForeground() (这创建了任务 isBackgrounded: false)及以后 backgroundTask() (翻转标志并安装完成处理程序)。 这两个步骤就是为什么您可以按 Ctrl+B 将正在运行的命令置于后台。

// isBackgrounded = false → task exists in AppState but is "owned" by
// the foreground UI. isBackgrounded = true → task is truly background.
export function isBackgroundTask(task: TaskState): task is BackgroundTaskState {
  if (task.status !== 'running' && task.status !== 'pending') return false
  // Foreground tasks (isBackgrounded === false) excluded from pill
  if ('isBackgrounded' in task && task.isBackgrounded === false) return false
  return true
}

摊位看门狗

由于 bash stdout 直接进入文件(无 JS 流),因此 Claude Code 无法侦听“45 秒内未收到数据”。 反而, startStallWatchdog() 每 5 秒轮询一次输出文件的大小。 如果大小在 45 秒内没有增长,它会读取最后 1KB 并检查尾部是否与 7 个提示检测模式中的任何一个匹配(例如, (y/n), Press Enter, Are you sure?).

如果检测到提示,则会发出一次性通知(无 <status> 标签(这会错误地关闭 SDK 消费者的任务)告诉模型该命令似乎正在等待交互式输入。 如果尾巴有 not 看起来像一个提示,看门狗重置计时器并再等待 45 秒 - 速度很慢但正在执行命令,例如 git log -S 永远不会触发误报。

const PROMPT_PATTERNS = [
  /\(y\/n\)/i,          // (Y/n), (y/N)
  /\[y\/n\]/i,          // [Y/n], [y/N]
  /\(yes\/no\)/i,
  /\b(?:Do you|Would you|Shall I|Are you sure)\b.*\? *$/i,
  /Press (any key|Enter)/i,
  /Continue\?/i,
  /Overwrite\?/i
]

孤儿清理

Shell 任务跟踪 agentId 产生它们的代理。 当代理退出时(其 runAgent() 最后阻止火势), killShellTasksForAgent() 通过匹配杀死任何仍在运行的 bash 任务 agentId。 这可以防止“僵尸”shell 进程比启动它们的代理寿命更长——这是一个真正的生产问题,观察到长期运行的后台脚本在代理完成后持续数天。

LocalAgentTask:进度跟踪、retain/evict、后台汇总

进度跟踪:两个独立的计数器,一个 TokenCount

The ProgressTracker inside LocalAgentTask.tsx 分别跟踪两件事: 输入标记 (每圈累计 — 始终是最新的 API 值)和 输出标记 (每回合 - 所有回合的总和)。 这是因为 Claude API 的 input_tokens 字段是累积的(包括所有以前的上下文)但是 output_tokens 是每回合。 对 input_tokens 求和会重复计算; 平均output_tokens会导致计数不足。 这 getTokenCountFromTracker() 函数返回 latestInputTokens + cumulativeOutputTokens.

export function updateProgressFromMessage(
  tracker: ProgressTracker,
  message: Message,
): void {
  const usage = message.message.usage
  // input is cumulative: keep the latest, don't sum
  tracker.latestInputTokens = usage.input_tokens
    + (usage.cache_creation_input_tokens ?? 0)
    + (usage.cache_read_input_tokens ?? 0)
  // output is per-turn: sum across all turns
  tracker.cumulativeOutputTokens += usage.output_tokens
  // ... track tool uses and recent activities ...
}

面板的保留/驱逐门

协调器任务面板中的代理任务有一个额外的 retain 布尔值和 evictAfter 时间戳。 当用户打开任务的记录视图时, retain 设置为 true ——这完全阻止了驱逐(无论 evictAfter)。 当任务完成并且用户取消选择它时, evictAfter 设置为 Date.now() + PANEL_GRACE_MS (30 秒)。 UI 可以显示已完成的任务 30 秒,然后消失。 这可以防止面板永远显示陈旧的已完成任务,同时仍然给用户时间查看结果。

背景总结

定期服务电话 updateAgentSummary() 存储任务状态的 1-2 句话进度摘要。 关键设计决策: updateAgentProgress() 刻意保留现有的 summary 字段 - 因此后台汇总结果永远不会被后续工具使用进度更新所破坏。 进度和摘要由不同的代码路径编写,不能相互覆盖。

RemoteAgentTask:轮询、完成检查器、评论提取

五种远程任务子类型

The remoteTaskType 字段区分: remote-agent, ultraplan, ultrareview, autofix-pr, 和 background-pr。 每个子类型可以注册一个 RemoteTaskCompletionChecker — 在每个轮询标记上调用的函数,返回一个非空字符串以表示完成,或返回 null 以继续轮询。 这使得自定义完成逻辑(例如,检查 PR 是否已合并)可插入,而无需修改核心轮询循环。

// Register a completion checker for a specific remote task type
export function registerCompletionChecker(
  remoteTaskType: RemoteTaskType,
  checker: RemoteTaskCompletionChecker,
): void {
  completionCheckers.set(remoteTaskType, checker)
}

// Called on every poll tick
type RemoteTaskCompletionChecker = (
  metadata: RemoteTaskMetadata | undefined
) => Promise<string | null>
// Return non-null string → task complete, string is the notification text
// Return null → keep polling

--resume 的元数据持久化

远程代理元数据(taskId、sessionId、remoteTaskType、remoteTaskMetadata)通过以下方式写入磁盘上的 sidecar 文件 writeRemoteAgentMetadata()。 在会话恢复时,将读取此 sidecar,并为任何未完成的任务重新建立实时轮询。 元数据在任务完成/终止时被删除,因此 --resume 不会复活已完成的任务。

评论内容提取:两位制作人

The ultrareview 路径有两个可能的内容源: bughunter 模式(run_hunt.sh 是 SessionStart 挂钩;其输出为 {type:'system', subtype:'hook_progress'})和提示模式(真正的克劳德助手将评论包装在 <remote-review> 标签)。 提取函数扫描两种格式,其中 hook_progress 优先。 concat-fallback 处理由于管道缓冲区分割而跨两个事件刷新的大负载。

InProcessTeammateTask:身份、空闲生命周期、消息上限

身份:agentName@teamName

每个队友都有一个存储在任务状态中的结构化身份 - agentId (例如。, "researcher@my-team"), agentName, teamName,以及 TUI 微调器显示的可选颜色。 身份反映了 TeammateContext 存储在 AsyncLocalStorage(它提供并发异步链之间的隔离)中,但作为纯数据存储在 AppState 中以供 UI 访问。

空闲与运行:运行中的生命周期

进程中的队友有一个 isIdle 独立于操作的布尔值 status 场地。 一名队友与 status: 'running' and isIdle: true 是“正在运行但正在等待工作”。 这种区别对于 UI 旋转器很重要(figures.ellipsis vs figures.play)和对于 describeTeammateActivity() 返回 'idle' 而不是 'working'。 空闲回调(onIdleCallbacks)允许领导者代理高效地等待而不进行轮询。

50 条消息 UI 上限

任务状态存储一个 messages 缩放记录视图的数组,但上限为 50 条消息。 生产分析显示,有 500 多个轮次代理持有每条消息的第二个完整副本,每个代理的 RSS 达到约 20MB。 在 2 分钟内启动 292 个代理的 Swarm 会话达到了 36.8GB 的​​ RSS。 这 appendCappedMessage() 函数会删除最旧的消息以使其保持在上限内:

export const TEAMMATE_MESSAGES_UI_CAP = 50

export function appendCappedMessage<T>(
  prev: readonly T[] | undefined,
  item: T,
): T[] {
  if (prev && prev.length >= TEAMMATE_MESSAGES_UI_CAP) {
    // Slice oldest, append new — always returns a new array
    const next = prev.slice(-(TEAMMATE_MESSAGES_UI_CAP - 1))
    next.push(item)
    return next
  }
  return [...(prev ?? []), item]
}
DreamTask:内存整合阶段,终止然后倒带模式

做什么梦

DreamTask 包装了自动梦想记忆巩固子代理——定期检查过去的会话记录并将见解巩固到长期记忆文件的后台代理。 任务本身是纯 UI 界面:它使原本不可见的分叉代理出现在页脚药丸和后台任务对话框中。

两相,无相位检测

DreamTask 有一个 phase 具有两个值的字段: 'starting' and 'updating'。 相变从 starting to updating 第一个时刻 Edit or Write tool_use 土地。 源注释明确指出:“我们不解析”梦想提示的实际四阶段结构(定向/收集/巩固/修剪)。 这是有意为之的——用户界面只需要传达“尚未开始写入”与“正在写入”的信息。

Kill-then-rewind

合并锁可以防止两个梦想进程同时运行(TOCTOU 竞争会损坏内存文件)。 击杀时, DreamTask.kill() 不仅中止代理而且还调用 rollbackConsolidationLock(priorMtime) ——将锁的时间倒回到这个梦开始之前的状态。 如果没有这个,被杀死的梦想将使锁处于“已声明”状态,从而阻止下一个会话继续做梦。 分叉失败时会触发相同的回滚路径。

async kill(taskId, setAppState) {
  let priorMtime: number | undefined
  updateTaskState<DreamTaskState>(taskId, setAppState, task => {
    if (task.status !== 'running') return task
    task.abortController?.abort()
    priorMtime = task.priorMtime       // capture before state wipe
    return { ...task, status: 'killed', notified: true, ... }
  })
  if (priorMtime !== undefined) {
    await rollbackConsolidationLock(priorMtime)
  }
}

没有面向模型的通知

梦想任务集 notified: true 立即在其完成/失败/终止处理程序中,而不将任何 XML 通知排入队列。 这是正确的:梦只是 UI 的——它在 TUI 中向人类展示活动,但模型不需要知道它发生了。 内联 appendSystemMessage 主会议记录中的完成注释是唯一的信号。

📣 通知系统

当任务完成时,它必须通知模型,以便它可以在下一轮处理结果。 通知是排队到的 XML 消息 messageQueueManager 具有特定的格式。 每个任务类型都会构建相同的 XML 信封:

// The canonical notification envelope
`<task_notification>
<task_id>${taskId}</task_id>
<tool_use_id>${toolUseId}</tool_use_id>   // links to spawning tool call
<output_file>${outputPath}</output_file>  // model reads this file for full output
<status>${status}</status>              // completed | failed | killed
<summary>${summary}</summary>          // human-readable 1-liner
</task_notification>`

优先级:下一个 vs 稍后

通知排队 priority field: 'next' 在下一回合开始时发送通知(立即可见),同时 'later' 将其排在任何挂起的正常消息之后。 Shell 任务使用 'later' 默认情况下; 监视任务(通过启用 MONITOR_TOOL 功能标志)使用 'next'。 这可以防止大量后台 shell 完成中断对话流。

停顿通知没有 <status> 标签——故意的

停顿看门狗通知(当命令似乎正在等待输入时发送)有意省略 <status> 标签。 原因很微妙: print.ts 治疗任何 <status> 值作为终端信号。 如果没有可识别的状态值,通知将被视为进度 ping - 任务保持打开状态。 与一个 <status> 标记设置为某个占位符值, print.ts 将落入“已完成”状态并错误地关闭 SDK 消费者的任务。

代理通知包括结果和使用情况

与 shell 任务通知不同,代理任务通知携带更丰富的负载:最终结果文本(通过 <result>)、令牌使用统计信息、持续时间以及可选的工作树路径/分支(如果代理在 git 工作树中运行):

// LocalAgentTask enqueueAgentNotification — richer than shell
const resultSection   = finalMessage  ? `\n<result>${finalMessage}</result>` : ''
const usageSection    = usage ? `\n<usage><total_tokens>${total}</total_tokens>...` : ''
const worktreeSection = worktreePath  ? `\n<worktree><path>${worktreePath}</path>...` : ''

💾 输出管理

每个任务在磁盘上都有一个输出文件 ~/.claude/projects/<project>/tmp/<sessionId>/tasks/<taskId>.output。 系统采用两层架构:

Layer Class Responsibility
DiskTaskOutput utils/task/diskOutput.ts 每个任务的写入队列刷新到单个文件句柄。 每次写入后立即对块进行 GC。 强制执行 5GB 上限,之后写入将被删除并发出截断通知。
TaskOutput utils/task/TaskOutput.ts 钩子/管道模式使用的更高级别的类。 内存中的缓冲区(默认为 8MB),溢出时溢出到 DiskTaskOutput。 还管理文件模式任务的共享进度轮询器。

文件模式与管道模式

Bash 命令使用 文件模式:stdout 和 stderr 通过操作系统级文件描述符重定向到输出文件。 JS 在执行过程中不会看到这些数据。 通过使用与 React 组件挂载/卸载生命周期相关的共享静态轮询器每 1 秒轮询一次文件尾部来提取进度。

挂钩执行和基于管道的输出使用 管道模式:数据流经 writeStdout()/writeStderr() 到内存缓冲区中。 当缓冲区超过8MB时,它通过以下方式溢出到磁盘 DiskTaskOutput。 在溢出状态下, getStdout() 仅返回最近 5 行并带有截断通知。

安全性:O_NOFOLLOW 防止符号链接攻击

所有输出文件打开都使用 O_NOFOLLOW 标志(如果可用 - 仅限 Unix)。 如果没有这个,沙盒环境中的攻击者可以在任务目录中创建指向任意文件的符号链接(例如, /etc/passwd),导致主机上的 Claude Code 在那里写入任务输出。 该标志导致打开失败 ELOOP 如果路径是符号链接,则完全防止攻击。

代理转录本使用符号链接

For local_agent 任务时,输出文件被初始化为代理脚本 JSONL 文件的符号链接,通过 initTaskOutputAsSymlink()。 这意味着读取任务输出文件相当于读取代理的转录本 - 没有数据重复。 增量读取系统(getTaskOutputDelta())使用字节偏移量,因此它可以有效地仅读取自上次轮询以来的新内容。

API 消耗的截断

在任务输出包含在模型通知中之前,它会被格式化并截断 formatTaskOutput()。 默认限制为 32,000 个字符(可通过配置 TASK_MAX_OUTPUT_LENGTH,上限为 160,000)。 截断的输出包括指向完整文件路径的标头,因此模型可以在需要时请求完整输出:

// outputFormatting.ts
export function formatTaskOutput(
  output: string,
  taskId: string,
): { content: string; wasTruncated: boolean } {
  const maxLen = getMaxTaskOutputLength()  // default 32_000
  if (output.length <= maxLen) return { content: output, wasTruncated: false }
  const filePath = getTaskOutputPath(taskId)
  const header = `[Truncated. Full output: ${filePath}]\n\n`
  const truncated = output.slice(-(maxLen - header.length))
  return { content: header + truncated, wasTruncated: true }
}

🧰 任务创建工具和任务更新工具

这两个工具使模型能够管理结构化的 任务清单 — 与上面的后台任务系统不同的概念。 任务列表是一个轻量级的项目管理系统:主题、描述、状态、所有权和依赖链(块/blockedBy)。 当 isTodoV2Enabled() 返回真。

TaskCreateTool

在活动任务列表中创建任务。 除了基本的创造之外,它还激发了 TaskCreated 钩子管道(可插入验证 - 如果任何钩子返回阻塞错误,则任务将被删除并引发错误)。 创建时,它会自动展开任务列表 UI 面板。

// The input schema — minimal by design
z.strictObject({
  subject:     z.string(),            // "Fix authentication bug in login flow"
  description: z.string(),            // what needs to be done
  activeForm:  z.string().optional(), // "Fixing authentication bug" (spinner text)
  metadata:    z.record(...).optional(), // arbitrary key-value
})

TaskUpdateTool:状态工作流程和自动所有权

更新现有任务的任何字段。 状态工作流程: pending → in_progress → completed, 和 deleted 作为完全删除任务文件的特殊操作。 关键行为:

  • Auto-ownership:当队友(启用代理群)标记任务时 in_progress 如果不指定所有者,该工具会自动将所有者设置为呼叫代理的姓名。 这使任务列表与实际执行保持同步。
  • 任务完成挂钩:标记任务 completed 激发 TaskCompleted 挂钩管道。 如果任何钩子返回阻塞错误(例如,测试失败),状态更新将被拒绝——当门失败时,模型无法将任务标记为完成。
  • 邮箱通知:当所有权发生变化并启用代理群时, task_assignment 邮箱消息写入新所有者的邮箱,使团队成员无需轮询即可发现分配的工作。
  • 验证推动:如果启用了功能标志,并且模型刚刚完成了 3 个以上任务中的最后一个,且列表中没有“verif*”任务,则工具结果将包含一个轻推以生成验证代理。
// The verification nudge — instructs the model to spawn a verifier
if (verificationNudgeNeeded) {
  resultContent += `\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.`
}

任务列表与后台任务:两个不同的系统

⚠️
两个不同的概念共享“任务”一词

The 后台任务系统 (Task.ts、LocalShellTask​​、DreamTask 等)跟踪 AppState 中的并发执行单元。 这 任务清单 (TaskCreateTool、TaskUpdateTool、utils/tasks.ts) 是模型的项目管理清单,以 YAML/JSON 文件形式存储在磁盘上。 它们是完全独立的——后台 bash 任务不是任务列表任务。

🖥️ UI:页脚药丸

页脚药丸(在 TUI 中可见,例如, ◇ 2 cloud sessions or 3 shells · Shift+Down) 是由生成的 getPillLabel() in tasks/pillLabel.ts。 它汇总了所有 background 任务(那些通过 isBackgroundTask())并给出一个紧凑的摘要:

后台任务 药丸标签
1 个 bash 任务1 shell
3 个 bash + 2 个监控任务3 shells, 2 monitors
1 个超级计划(needs_input)◇ ultraplan needs your input
1 个超级计划(plan_ready)◆ ultraplan ready
来自 2 个团队的 5 名正在进行中的队友2 teams
1 梦想任务dreaming
混合型N background tasks

The pillNeedsCta() 函数控制是否 · ↓ to view 附加号召性用语 — 它仅针对处于注意力状态的单个 ultraplan 任务触发(needs_input or plan_ready),不适用于普通运行的任务。

当旋转器树处于活动状态时,进程中的队友将在旋转器树中渲染(每个队友一行),而不是页脚药丸中。 shouldHideTasksFooter() 检查每个后台任务是否都是正在进行的队友,并在这种情况下隐藏页脚药丸以避免重复。

🏗️ 框架内部:registerTask 和 updateTaskState

中的两个实用函数 utils/task/framework.ts 处理所有任务状态突变。 了解它们是理解所有七种任务类型如何保持一致的关键:

registerTask

registerTask(task, setAppState) 将任务插入到 AppState.tasks。 如果具有相同 ID 的任务已经存在(“恢复”路径),它将继承 UI 状态 — retain, startTime, messages, diskLoaded, pendingMessages。 这允许后台代理在会话中恢复,而不会丢失用户的记录视图或滚动位置。 注册后,会出现一个 task_started 发出 SDK 事件(跳过替换以避免重复发出)。

updateTaskState

updateTaskState<T>(taskId, setAppState, updater) 将更新程序功能应用于特定任务。 如果更新程序返回相同的引用(提前退出无操作),则 setAppState 调用被跳过 - React 订阅者不会在未更改的状态下重新渲染。 这种引用相等性检查对于频繁更新的任务(例如带有活动看门狗的 shell 命令)来说是一项重大优化。

export function updateTaskState<T extends TaskState>(
  taskId: string,
  setAppState: SetAppState,
  updater: (task: T) => T,
): void {
  setAppState(prev => {
    const task = prev.tasks?.[taskId] as T | undefined
    if (!task) return prev
    const updated = updater(task)
    if (updated === task) return prev // reference-equal → no-op, no re-render
    return { ...prev, tasks: { ...prev.tasks, [taskId]: updated } }
  })
}

轮询循环

pollTasks() 以 1 秒的间隔运行。 它调用 generateTaskAttachments(),它读取正在运行的任务的输出增量(基于字节偏移,从不加载整个文件),然后逐出终端+通知的任务。 偏移补丁应用于 fresh 状态(不是预等待快照),以防止在异步磁盘读取期间可能发生的并发转换。

📊 类型比较参考

Type ID前缀 输出文件 Notification 杀伤机制
local_bash b 通过 OS fd 直接文件 带有退出代码的 XML, 'later' priority 通过 shellCommand.kill() 发出 SIGKILL
local_agent a 符号链接 → 转录 JSONL XML 包含结果 + 用法,无优先级 abortController.abort()
remote_agent r 投票 + 本地编写 通过轮询完成的 XML archiveRemoteSession() API 调用
in_process_teammate t 符号链接 → 转录 JSONL 无(团队通过邮箱协调) killInProcessTeammate()
local_workflow w 任务输出文件 XML + SDK task_progress 事件 abortController.abort()
monitor_mcp m 任务输出文件 XML, 'next' priority 进程终止
dream d None 无(仅限 UI,立即通知=true) abortController.abort() + 锁定倒带

要点

  • 一个接口,七个实现。 所有任务类型共享 TaskStateBaseTask.kill() 界面。 变化在于状态形状、输出策略和通知格式,而不是框架机制。
  • 通知标志是一个原子单向锁存器。 每个任务类型内部都使用相同的比较和设置模式 updateTaskState 确保只有一个通知到达模型,无论有多少异步路径竞相完成。
  • Bash 输出在执行期间从不接触 JS。 Stdout/stderr 通过操作系统文件描述符直接转到文件。 通过轮询文件尾部来提取进度。 这消除了长时间运行或输出量大的命令的背压问题和 JS 堆压力。
  • O_NOFOLLOW 是一个安全决定,而不是性能决定。 使用此标志打开任务输出文件可防止沙盒环境中的攻击者创建将 Claude Code 的写入重定向到任意主机文件的符号链接。
  • DreamTask 终止合并锁定。 如果终止路径跳过锁回滚,则终止的梦想将永久阻止下一个会话运行内存整合。 这 priorMtime 字段的存在只是为了启用此回滚。
  • 任务列表(TaskCreate/TaskUpdate)和后台任务是完全独立的系统。 其中之一是项目管理清单。 另一个是运行时执行注册表。 他们共同使用“任务”这个词,但除此之外别无其他。
  • 50 条消息的 UI 上限是生产必需的。 真实的 swarm 会话达到 36GB RSS,因为进程中的队友任务保存了每条 API 消息的第二个完整副本。 该上限不是 UI 限制,而是内存安全阀。

🧠 Quiz

1.您看到任务ID t3a9bx2f 在后台任务对话框中。 在没有任何其他上下文的情况下,它是什么类型的任务?

2. bash 命令已运行 60 秒,输出没有增长。 它的最后一行是 Continue? [y/N]。 Claude Code 有什么作用?

3. 为什么 updateTaskState 帮助程序在调用之前检查更新程序是否返回相同的对象引用 setAppState?

4. DreamTask 在运行中被终止。 合并锁会发生什么情况,为什么?

5. 正在进行中的队友 status: 'running' and isIdle: true。 这位队友在做什么?

6.为什么LocalAgentTask通知系统总和 output_tokens 穿过转弯但始终保持 latest input_tokens 而不是对它们求和?

7. 其主要目的是什么 O_NOFOLLOW 打开任务输出文件时标记?