长时间运行的工作管理
问题
如果没有结构化的工作管理层,并发 Agent 工作会在共享状态上碰撞:两个子 Agent 写入同一内存缓冲区会损坏彼此的输出,没有规范的信号表示"这项工作已完成且不会再变",父级协调者也没有安全的窗口从内存中驱逐已完成的工作。未类型化的工作标识符使得在规模上无法将终止信号或状态查询路由到正确的处理器。
这些问题出现在任何支持并发或后台工作的 Agent 运行时中——并非特定于某个具体实现。
黄金法则
每个工作单元获得类型化标识
在创建时为每个异步工作单元分配一个带前缀的类型化 ID。类型前缀编码了工作的类别(shell 命令、Agent、远程 worker、队友等),使日志过滤、路由和终止分发无需解析额外字段即可明确。使用抗碰撞的随机后缀——ID 空间应足够大,即使在敌对环境中暴力碰撞也不可行。
严格的状态机与永久终态
每个工作单元遵循相同的生命周期:启动(大多数直接注册为"运行中",跳过任何"待处理"阶段)、运行,然后以恰好一种终态结束——已完成、已失败或已终止。终态是永久的。将此不变式编码在单一的规范检查函数中,并在所有地方使用该函数,而非内联状态比较。如果将来添加新的终态,内联副本会静默偏离。
输出写入磁盘;内存只保存偏移量
为每个并发子 Agent 在内存中保持完整记录是无界的。相反,将输出写入每个工作单元的磁盘文件。内存状态只存储读取偏移量。每次轮询周期,读取上次偏移后的增量并原子地推进。这使内存占用恒定,无论工作单元运行多久。
驱逐是两阶段的,由通知门控
当工作到达终态时:
- 磁盘清理立即进行——输出文件在终态转换时被移除。
- 内存清理延迟进行——内存中的记录只在父级被通知结果后才移除。
通知门控至关重要。没有它,框架会在父级能读取结果之前删除工作记录,造成驱逐与结果检索之间的竞态。
适用场景
- 你的 Agent 生成并发子 Agent 或后台任务。
- 你需要跟踪超出单轮生命周期的工作。
- 你需要在 UI 中显示任务状态或将终止信号路由到特定工作单元。
- 长时间运行的 Agent 产生的输出太大无法保留在内存中。
- 你需要一个不与结果消费竞态的干净 GC 策略。
权衡
| 决策 | 收益 | 代价 |
|---|---|---|
| 带前缀的类型化 ID | 明确的路由,方便的日志搜索 | 多一个需要生成和传播的字段 |
| 实践中跳过"待处理" | 更简单、更快的注册 | 假设"待处理"为必需阶段的 UI 会出错 |
| 磁盘支持的输出 | 恒定内存,可在中断后存活 | 每次轮询的 I/O 延迟,磁盘清理义务 |
| 两阶段驱逐 | GC 与结果检索之间无竞态 | 更复杂的生命周期——两个阶段都必须执行 |
| 通知门控的 GC | 父级始终看到结果 | 未通知的终态任务无限期泄漏内存 |
| UI 的保留标志 | 用户可以查看已完成的工作 | 必须被显式清除否则任务泄漏 |
实现模式
- 在分配任何状态之前铸造一个带前缀的类型化 ID。该 ID 是工作单元在其整个生命周期的身份。
- 通过工厂函数初始化共享的基础字段(状态、输出文件路径、读取偏移量)。工厂设置一个安全的默认状态;具体构造器如果工作立即开始则覆盖为"运行中"。
- 通过单一入口点注册工作到共享状态。永远不要直接写入任务存储——注册函数是验证和去重的咽喉。
- 对于 Agent 类型的工作,将输出文件初始化为指向 Agent 现有记录的符号链接。这避免了复制并使输出文件立即可解析。
- 在注册时或之前转换为"运行中"。基础工厂的"待处理"状态是类型系统的安全默认值,而非真实任务要经过的阶段。
- 使用泛型的、类型参数化的更新函数进行所有变更。当更新器返回同一引用时跳过状态展开以防止虚假重渲染。
- 每次终态转换时:设置结束时间戳,清理磁盘输出,设置驱逐截止时间(除非工作正在被主动查看)。
- 每次终态转换恰好入队一次父级通知。使用更新函数内的"已通知"标志使入队幂等。
- 在进程级注册清理处理器,在关闭时终止所有运行中的工作。
- 防范偏移量补丁对过时状态的覆盖:异步磁盘读取后,在应用新偏移量之前重新检查工作单元的状态。工作单元可能在读取期间已完成。
踩坑指南
不要在父级被通知之前驱逐。 当通知标志为 false 时,驱逐函数静默地不操作。如果你过早调用它,工作单元将无限期留在内存中,父级永远不会获知结果。
被保留的工作单元永远不会被自动驱逐。 当工作单元正在 UI 中被主动查看时,其驱逐截止时间被设为无穷大。UI 必须在用户导航离开时显式清除保留标志——否则终态工作单元永久泄漏。
更新函数不能修改现有状态对象。 返回新对象或原引用。原地修改对不可变更新模式不可见,不会传播到订阅者。
不要在异步边界上保持完整状态快照。 如果你在异步磁盘读取前存储完整的工作单元状态,读取期间发生的并发终态转换会在你将过时快照展开回去时被覆盖。只存储你需要的字段(如新偏移量)。
使用规范的终态检查,而非内联比较。 如果将来添加新的终态,内联的 status === 'completed' || ... 副本会静默偏离权威检查。
驱逐是两阶段的——跳过任一阶段都会泄漏资源。 跳过磁盘清理泄漏文件。跳过内存清理泄漏状态存储条目。两者都必须执行才能干净 GC。
终止对非运行工作是空操作。 终止守卫只对"运行中"的工作单元生效。双重终止是安全的,但你不能终止一个"待处理"的单元——等它变成"运行中"再说。
Claude Code 实证
Claude Code 的任务系统是这些原则的生产实现,并发管理七种不同的工作类型:
带前缀的类型化 ID。 每种任务类型有一个单字符前缀(Agent、bash、远程、队友、工作流、监控、dream)。剩余字符取自大小写不敏感安全字母表(数字 + 小写字母),每个前缀产生约 2.8 万亿种组合。设计注释指出这一空间刻意足够大,以抵抗对输出文件路径的暴力符号链接攻击。
实践中跳过"待处理"。 基础工厂将状态初始化为"待处理",但在实践中,Agent 任务、远程任务和 dream 任务都在注册时覆盖为"运行中"。"待处理"状态作为类型系统的安全默认值存在,而非真实任务经过的阶段。
基于偏移量轮询的磁盘支持输出。 Agent 任务输出被写入每个任务的磁盘文件。对于本地 Agent 任务,输出文件被初始化为指向 Agent 现有 JSONL 记录的符号链接——不复制数据。框架在"运行中"状态期间轮询此文件,只读取上次偏移后的增量。偏移量在异步读取后原子推进,并有显式注释说明避免覆盖在 await 期间可能发生的状态转换。
带通知门控的两阶段驱逐。 终态任务设置驱逐截止时间(默认 30 秒)以给 UI 时间显示完成状态。被主动查看的任务设置"保留"标志完全阻止驱逐。驱逐函数检查"已通知"标志,如果父级还未被通知则不操作。内存记录在单独的扫描周期中被延迟清理。
会话级任务 ID。 核心任务 ID 前缀映射有七个条目。单独的会话管理任务类型在核心映射之外生成自己的前缀,使用相同的底层本地 Agent 任务机制但具有不同的身份命名空间。这种分离保持通用任务系统的整洁,同时允许专门化的复用。