从零实现 Agent Harness 系列 · 第 06 篇
这一篇讲三个能力:
Persistent Tasks、Background Tasks和Cron Scheduler。它们分别回答三个问题:任务状态怎么保存,长动作怎么异步执行,未来任务怎么定时回来。
前言
一个最小 Agent 很适合处理短任务。
比如:
text
读一个文件
改一个函数
跑一次测试
解释一下结果
这类事情通常可以在一次对话、一次主循环里做完。
但真实工作不是这样的。
真实工作经常会拖得更久,也更像一个过程:
- 完整测试套件可能要跑很久
- 某个问题今天先记下来,明天还要继续查
- 构建、扫描、部署这类动作不适合一直占着主循环
- 有些检查需要每天、每周、每隔一段时间自动执行
- 用户下次回来时,希望 Agent 还能知道上次做到哪里
这时,单纯依赖"当前对话 + 当前上下文"就不够了。
长期运行至少意味着三件事:
text
状态不能只活在 messages 里
长动作不能一直阻塞 agent loop
未来工作要能自动回到主循环
这一篇要讲的三层机制,正好分别对应这三个核心问题:
text
Persistent Tasks:任务状态如何持久化
Background Tasks:长时间动作如何异步执行
Cron Scheduler:未来任务如何定时触发
它们合在一起,才让 Agent 从"即时问答工具"开始变成"可以持续推进工作的系统"。
一、为什么 Agent 需要长期运行能力
前面几篇已经解决了不少基础问题:
- Agent Loop 让模型能调用工具
- 临时步骤记录让模型能管理当前任务进度
- Subagent 让探索过程隔离出去
- Skill Loading 让模型按需加载专业知识
- Context Compact 让长上下文可以被压缩
这些能力已经能让 Agent 做出不少像样的事情,但它们主要还是围绕"当前会话"展开的。
只要任务跨越时间,问题就会立刻冒出来。
1. 任务状态会丢
如果任务只写在 messages 里,它就不是稳定状态。
因为对话历史本质上是"给模型看的上下文",不是"给系统保存的状态库"。
这两者看起来很像,但职责完全不同:
- 上下文的目标是让模型理解当前情况
- 状态的目标是让系统在未来还能恢复工作
一旦把这两种职责混在一起,就会出现很多问题:
- 进程退出后,内存里的东西可能直接丢失
- 上下文压缩时,重要细节可能被摘要掉
- 多个任务之间的依赖关系很难维护
- 用户隔一天再回来时,Agent 可能只记得"大概做过什么",却不知道精确进度
所以长期任务不能只靠聊天记录保存。
2. 长命令会卡住主循环
普通工具调用通常是同步的:
text
模型调用工具
程序执行命令
等待命令结束
结果返回模型
这对短命令没问题,比如 pwd、rg、python -m py_compile。
但如果是:
- 完整测试
- 构建产物
- 安装依赖
- 启动服务
- 扫描大仓库
- 跑数据处理脚本
主循环就会一直卡在"等待命令结束"这一格。
这时最麻烦的地方不是"慢",而是"慢的时候 Agent 什么都做不了"。
它不能继续规划,不能顺手处理其他独立任务,也不能先整理已经拿到的信息。
3. 未来事件无法自动回来
还有一些任务根本不是"现在就做",而是"未来做"。
比如:
- 每天早上检查一次项目状态
- 每 10 分钟查看一次后台任务
- 每周生成一次工作摘要
- 明天继续跟进某个未完成问题
如果没有调度机制,Agent 只能等用户再次提醒它。
这说明长期运行的核心,不是"让一个 loop 永远不退出",而是建立三条能力链路:
text
能保存状态
能异步等待
能被未来事件唤醒
4. 这三件事为什么要拆开做
很多人刚开始会直觉地想:
"能不能做一个更大的 Agent Loop,把这些问题一次性都包进去?"
当然可以,但那样很快会把不同职责揉成一团。
因为这三类问题其实属于三个不同层次:
Persistent Tasks管的是"状态"Background Tasks管的是"执行通道"Cron Scheduler管的是"触发时机"
把它们拆开有两个好处。
第一,概念清楚。你会知道每一层到底在解决什么,不会把"任务存储""后台执行""定时唤醒"混成一个模糊的大功能。
第二,实现更稳。状态层可以单独升级成数据库,后台执行层可以单独升级成 worker 队列,调度层也可以单独接 webhook 或外部事件,不会互相绞在一起。
所以这篇文章虽然讲的是三个机制,但本质上是在搭一条"长期工作能力链"。
二、Persistent Tasks:让任务活在对话之外
第一层是 Persistent Tasks。
它解决的是一个很基础、但很关键的问题:
text
任务状态应该放在哪里?
对当前请求来说,用一份临时步骤清单来跟踪进度已经够用了。
但这类状态通常只存在于当前运行过程中。
一旦任务要跨轮、跨会话继续推进,就需要把状态写到对话外部。
1. 原理:把"上下文里的计划"变成"系统里的记录"
这里最核心的思想并不复杂:
任务不应该只是模型脑子里的一串待办项,而应该变成一份系统能读、能写、能恢复的结构化记录。
只要任务被结构化保存下来,系统就能做很多对话本身做不到的事情:
- 精确恢复某个任务的状态
- 查询有哪些任务还没完成
- 表达任务之间的依赖关系
- 在将来某个时间点继续推进同一个任务
换句话说,Persistent Tasks 不是让 Agent "记得更多",而是让 Agent 有一个对话之外的状态层。
2. 这一份实现怎么做
在这份实现里,这一层有两个核心部件:
.tasks/:保存任务 JSON 文件的目录TaskManager:负责创建、读取、更新任务,并维护依赖关系
一个任务大致长这样:
json
{
"id": 3,
"subject": "修复支付回调重试",
"description": "排查失败原因并补回归测试",
"status": "pending",
"blockedBy": [2],
"owner": ""
}
这里最关键的是几个字段:
id:任务唯一标识,方便查询和建立依赖subject:任务标题,让模型和人都知道这是什么description:更完整的说明,避免标题过短导致语义不够status:当前状态,比如pending、in_progress、completedblockedBy:当前任务被哪些上游任务阻塞
这一步的意义,是把"聊天里的任务清单"升级成"可恢复、可查询、可维护依赖关系的任务系统"。
3. 从工具调用到文件落盘
模型如果调用:
text
task_create(subject, description)
程序不会只在对话里多留一句"我记住了",而是会真的写入:
text
.tasks/task_<id>.json
后续模型可以再通过:
text
task_list
task_get
task_update
读取和更新这些任务。
这意味着任务状态第一次从"语言里的承诺"变成了"系统里的事实"。
只要文件还在,哪怕:
- 这轮对话结束了
- 上下文被压缩了
- 进程重启了
任务也依然存在。
4. 依赖关系为什么重要
如果任务只有"创建"和"完成"两个动作,它最多只是个记事本。
真正让它更像任务系统的,是依赖关系。
比如:
text
task 1:定位支付回调失败原因
task 2:修复重试逻辑
task 3:补回归测试
其中 task 2 依赖 task 1,task 3 依赖 task 2。
那么系统里就可以表示成:
text
task 2 blockedBy [1]
task 3 blockedBy [2]
这样一来,Agent 不只是"知道有三件事要做",而是"知道这三件事该按什么顺序推进"。
对长期任务来说,这一点非常重要。
因为一旦工作跨度变长,真正难的往往不是"记住有哪些事",而是"记住哪些事现在可以做,哪些事还得等上游完成"。
5. 完成任务时,为什么要自动清依赖
这份实现里还有一个细节很重要:
当一个任务被标记为 completed 时,TaskManager 会自动清理其他任务里的依赖。
比如原来是:
text
task 2 blockedBy [1]
task 3 blockedBy [2]
当 task 1 完成后,task 2 会变成:
text
task 2 blockedBy []
这说明 task 2 不再被 task 1 阻塞,可以继续推进。
这个动作看起来只是一个小细节,但它其实是在维护任务图的一致性。
如果没有这一步,系统就会出现一种很常见的问题:
- 上游任务已经完成
- 下游任务理论上可以开始
- 但状态里仍然写着"被阻塞"
于是 Agent 看到的是一份过时状态,后面的自动化能力也会跟着失真。
6. 一个具体例子
假设用户说:
text
帮我排查支付回调失败问题,这件事今天先记着,明天继续。
如果没有持久化任务,Agent 最多只能在当前上下文里记一笔。
但有了 Persistent Tasks,它可以把这件事明确登记成一个长期任务:
text
subject: 修复支付回调失败
description: 先定位失败原因,再修复重试逻辑并补测试
status: pending
接下来即使用户离开,第二天再回来,系统仍然能通过 task_list 或 task_get 找到这件事。
这就是"任务真的活在对话之外"的意思。
7. 必要说明:这一层解决什么,不解决什么
Persistent Tasks 解决的是"任务状态如何长期存在"。
它不直接解决两件事:
- 它不负责执行耗时动作
- 它不负责决定未来什么时候触发
所以它更像一个底座。
没有它,长期任务很容易变成一团模糊记忆。
有了它,后面的后台执行和定时调度才有地方落脚。
三、Background Tasks:让长动作不阻塞
任务状态能存下来以后,第二个问题是:
text
耗时动作应该怎么跑?
很多动作本身就很慢:
- 跑完整测试
- 执行构建
- 安装依赖
- 启动服务
- 扫描大仓库
- 跑数据处理脚本
如果还像最小 Agent Loop 那样同步执行,主循环就会一直等待。
1. 原理:异步的重点不是更快,而是不阻塞
很多人提到后台任务时,第一反应是"为了并行"。
但对 Agent 来说,更本质的价值通常不是更快,而是不阻塞。
因为主循环被卡住时,损失的不只是几秒钟时间,而是整个决策链条被迫暂停:
- 模型不能继续分析已知信息
- 不能先处理其他独立任务
- 不能把等待中的动作和已经完成的动作分开管理
所以 Background Tasks 的目标不是把所有事情都并行化,而是把"需要等很久"的事情从主循环里挪出去。
2. 这一份实现怎么做
Background Tasks 的思路是:
让长命令在后台执行,主 Agent 先拿到一个 task_id,然后继续推进其他工作。等后台任务完成后,再把结果带回上下文。
在这份实现里,核心部件是 BackgroundManager,里面主要有三样东西:
tasks:保存后台任务状态_notification_queue:保存已经完成、等待交付的结果_lock:保证并发读写时状态一致
一个后台任务的状态大致像这样:
json
{
"status": "running",
"result": null,
"command": "pytest tests/test_checkout.py"
}
也就是说,后台层不仅要"启动线程",还要维护一份独立状态表。
3. 一次后台执行的完整链路
一次典型流程是这样的:
text
模型调用 background_run
↓
程序启动后台线程
↓
立即返回 task_id
↓
Agent 继续处理其他工作
↓
后台任务完成后写入通知队列
↓
下一次调用模型前,把结果注入 messages
比如模型调用:
text
background_run("pytest tests/test_checkout.py")
程序不会等 pytest 跑完,而是立刻返回:
text
Background task a1b2c3d4 started: pytest tests/test_checkout.py
这时主循环没有被卡住。模型知道测试已经在跑,就可以先去读相关代码、检查最近修改,或者处理另一个独立任务。
4. 为什么完成结果不直接打断当前对话
这里有个很容易被忽略的设计点:
后台任务完成后,结果不是立刻"闯进来"打断当前执行,而是先进入通知队列。
下一轮真正请求模型前,主循环会调用类似:
text
inject_background_notifications(messages)
把结果整理成一条普通消息:
text
<background-results>
[bg:a1b2c3d4] completed pytest tests/test_checkout.py: 12 passed, 1 failed
</background-results>
这么做的原因很重要。
因为主循环里的 messages、工具调用结果、上下文注入,本来就是一条相对线性的工作流。
如果后台线程一完成就直接并发改上下文,系统很容易出现几类问题:
- 结果插入时机混乱
- 当前对话状态被突然打断
- 多个后台任务同时返回时互相打架
所以更稳的做法是:
text
先完成
先入队
再在安全时机交给主循环
5. 这层真正重要的是"回流链路"
很多时候我们会把后台任务理解成"开了一个线程"。
但线程只是最表面的实现细节。
这一层真正重要的不是"开线程"本身,而是完整的回流链路:
text
异步启动
状态可查
完成后入队
下一轮回到上下文
少任何一环,体验都会差很多。
比如:
- 只有异步启动,没有状态查询,模型就不知道任务跑到哪了
- 只有状态查询,没有结果回流,模型就得反复主动轮询
- 只有结果回流,没有独立状态表,系统就难以管理多个后台任务
所以 Background Tasks 的关键,不是"后台运行",而是"后台运行之后还能重新接回主循环"。
6. 一个具体例子
假设 Agent 正在排查一个测试失败问题。
它可以一边:
- 启动
pytest - 读取相关模块代码
- 查看最近改动
- 整理可能的故障点
等测试跑完以后,再根据返回结果决定下一步。
这时 Agent 的工作方式就从:
text
启动测试
傻等
拿到结果
再思考
变成了:
text
启动测试
继续推进别的工作
结果回来后再整合决策
这个差别,正是"同步工具调用"和"长期工作系统"的分水岭之一。
7. 必要说明:这一层的边界和限制
这份教学实现已经把主线跑通了,但它还是一个最小版本。
它目前主要演示的是:
- 如何把长命令放到后台
- 如何维护后台状态
- 如何把完成结果重新交给主循环
它还没有覆盖很多生产级问题,比如:
- 取消任务
- 重试任务
- 更细粒度的失败原因
- worker 隔离
- 日志持久化
- 并发配额和优先级
所以这一层最好理解成:它先把"主循环不再被长动作卡死"这件事建立起来。
真正的工业化版本,通常会进一步演化成 job queue 和 worker 系统。
四、Cron Scheduler:让 Agent 在未来自己醒来
Persistent Tasks 解决状态保存。
Background Tasks 解决当前已经开始的长动作。
第三层 Cron Scheduler 解决的是另一个维度:
text
未来某个时间点,怎么自动把任务重新交给 Agent?
比如用户说:
text
每周一早上 9 点检查一次项目状态,并汇总未完成任务
这不是一个"现在立刻执行"的任务,而是一条未来规则。
1. 原理:把"未来发生什么"从当前对话里解耦出来
没有调度器时,系统通常只有两种触发方式:
- 用户当前发来一条消息
- 程序当前正在执行一条链路
但未来任务不是这样。
它的特点是:
- 现在先注册
- 到点再触发
- 触发后重新交回主循环
所以调度层的核心职责不是"替模型思考",而是"在合适的时候重新制造一次输入"。
换句话说,调度器本身不是业务执行者,它只是未来事件的搬运工。
2. 这一份实现怎么做
在这份实现里,模型可以调用:
text
schedule_cron(cron, prompt, recurring, durable)
注册一条定时任务。
如果设置 durable=True,任务会写进:
text
.scheduled_tasks.json
这样程序重启后,定时任务还能重新加载回来。
一个定时任务大致长这样:
json
{
"id": "cron_102314",
"cron": "0 9 * * 1",
"prompt": "检查项目状态并汇总未完成任务",
"recurring": true,
"durable": true
}
这里的 cron 是标准 5-field cron 表达式:
text
minute hour day-of-month month day-of-week
比如:
text
0 9 * * * 每天 09:00
*/10 * * * * 每 10 分钟
30 18 * * 1 每周一 18:30
3. 为什么调度器不直接调用模型
这份实现里最重要的设计点,其实不是 cron 表达式,而是触发后的交付方式。
它没有让 scheduler 线程直接调用模型,而是拆成了三层:
text
scheduler 线程负责到点入队
queue processor 负责等待 Agent 空闲
agent loop 负责消费定时任务
为什么要这样设计?
因为模型调用和工具执行都会读写 messages。
如果 scheduler 线程在任意时刻直接调用模型,就可能和用户当前输入、工具调用、后台通知发生并发冲突。
所以更稳的做法是:
text
时间到了
↓
先放进队列
↓
等 Agent 空闲
↓
再安全触发一轮 agent_loop
这也是为什么实现里会出现:
- 调度线程
- 队列
- queue processor
- agent lock
它们一起保证"未来事件能回来",但"回来时仍然走统一入口"。
4. Cron 匹配和 durable 的意义
代码里还有两个值得补充的点。
第一,调度器会先校验 cron 表达式,再决定是否注册。
这一步不是可有可无的。
因为只要输入一条非法规则,系统后面就会一直带着一颗定时炸弹。最好的做法是注册时就把错误挡住,而不是等运行时才发现。
第二,durable=True 说明这一条规则不只活在内存里,还要保存到磁盘。
这跟前面的 Persistent Tasks 是同一个思路:
- 内存适合当前运行态
- 落盘适合跨会话恢复
如果一个定时规则注册完,程序一重启就消失,那它其实不算真正的长期调度能力。
5. 触发之后,系统里到底发生了什么
到点之后,定时任务会先被放进内部队列。
再往后,queue processor 会在 Agent 空闲时把它交给主循环。
最终,定时任务会被注入成一条普通消息:
text
<scheduled-work>
[cron:cron_102314] 检查项目状态并汇总未完成任务
</scheduled-work>
模型并不需要学习一套全新的执行通道。
它只需要像处理普通用户输入一样,处理这条 scheduled work。
这一点非常重要,因为它意味着:
- 调度器只负责"送达"
- 主循环继续负责"理解、规划、调用工具、写结果"
职责边界非常清楚。
6. 一个具体例子
假设用户让 Agent:
text
每隔 10 分钟检查一次后台测试是否结束,如果结束了就汇总结果
那么系统可以做两件事:
第一,用 Background Tasks 启动测试。
第二,用 Cron Scheduler 注册一个定时检查。
到点之后,主循环会再次被唤醒,并看到一条新的 scheduled work。此时它就能去:
- 查询后台任务状态
- 读取测试结果
- 更新长期任务状态
- 决定是否继续调试或通知用户
这说明调度层不是孤立存在的,它天然会和前两层协作。
7. 必要说明:cron 只是触发器,不是完整事件系统
这一层解决的是"未来如何触发"。
但 cron 只是最容易讲清楚的一种触发方式。
更完整的系统里,唤醒 Agent 的事件还可能来自:
- webhook
- 外部系统状态变化
- 文件更新
- 用户回访
- 后台任务完成
- 监控告警
所以更准确地说,Cron Scheduler 是"未来触发入口"的第一个版本。
先把时间触发跑通,后面再扩展到更多事件源,会更自然。
五、三层机制如何接在一起
把三层连起来看,长期运行的 Agent 其实有一条很清楚的数据流。
1. 先有任务状态,知道"要做什么"
用户或模型创建长期任务:
text
模型调用 task_create
↓
TaskManager 生成 id
↓
写入 .tasks/task_<id>.json
这解决的是:
text
任务不要只存在于上下文里
从这一刻起,系统里开始有了一份正式任务记录。
2. 再把长动作放到后台,知道"什么正在跑"
模型启动耗时命令:
text
模型调用 background_run
↓
BackgroundManager 创建 task_id
↓
后台线程执行命令
↓
完成后写入通知队列
↓
下一轮注入 <background-results>
这解决的是:
text
长动作不要阻塞主循环
也就是说,系统不只知道"要做什么",还知道"哪些事情正在独立推进"。
3. 最后用调度器补上未来触发,知道"什么时候再做"
模型注册定时任务:
text
模型调用 schedule_cron
↓
CronScheduler 保存 job
↓
到点后 job 进入队列
↓
queue processor 等 Agent 空闲
↓
下一轮注入 <scheduled-work>
这解决的是:
text
未来的工作要能重新进入主循环
于是系统就不再只依赖"用户此刻有没有发消息"。
4. 用一个完整场景把三层串起来
假设用户说:
text
帮我修一个 CI 失败问题。先跑完整测试,今天先记着。如果今晚还没处理完,明天早上 9 点再提醒我继续。
这句话里,其实同时包含了三类需求。
第一,Agent 需要把"修复 CI 失败"记录成长期任务。
这时会用到 Persistent Tasks。
第二,Agent 需要启动完整测试,但测试可能很慢,不能一直卡住主循环。
这时会用到 Background Tasks。
第三,Agent 需要在未来某个时间点重新把这件事带回来。
这时会用到 Cron Scheduler。
如果把它们合起来,整个链路就会变成:
text
记录长期任务
↓
后台跑测试
↓
主循环继续做别的事
↓
测试结果回流
↓
如果任务仍未完成,明早定时唤醒
↓
继续推进同一个任务
到这里,Agent 的工作方式已经明显不是"问一句答一句"了。
它开始具备一种更像真实工作流的节奏:
- 有状态
- 有等待
- 有未来触发
5. 为什么说这三层缺一不可
如果只做其中一层,系统都会有明显短板。
只有 Persistent Tasks:
- 能记住任务
- 但不会异步跑长动作
- 也不会在未来自动醒来
只有 Background Tasks:
- 能把长动作放后台
- 但任务本身不一定能长期保存
- 未来也没有统一触发机制
只有 Cron Scheduler:
- 能定时唤醒
- 但被唤醒后可能没有稳定状态可读
- 也没有后台执行通道可用
所以这三层不是彼此独立的小功能,而是一套连续工作机制:
text
Persistent Tasks 保存"要做什么"
Background Tasks 执行"正在跑什么"
Cron Scheduler 触发"什么时候再做"
六、从教学实现走向工业级 Agent,还差哪些能力
这份实现是教学版本,已经把长期运行的主线跑通了。
如果继续往产品级 coding agent 靠,重点不只是"多加几个工具",而是把状态、执行、触发、安全边界和隔离环境一起设计。
Claude Code 和 Codex 提供了两个很好的参照。
它们表面上都有读代码、改文件、跑命令、调用工具、skills、hooks、MCP、子代理这些能力,但长期工作系统的组织方式并不一样。
Claude Code 更像是从"当前会话"出发,把一次工作会话不断增强:用 CLAUDE.md 和 auto memory 保存规则与经验,用 subagents 拆分探索和规划,用 background tasks 挂出长动作,再用 hooks、permissions 和 worktree isolation 约束执行过程。
Codex 更像是从"长期工作主线"出发,把 Agent 放进 thread、worktree、automations、inbox 这些结构里:AGENTS.md 和 Memories 提供稳定上下文,threads 承载持续推进的工作,worktrees 隔离并行执行,automations 负责未来触发和结果回流。
所以它们不是简单地"功能谁多谁少",而是在回答同一组架构问题:
text
状态放在哪里?
长动作在哪里跑?
未来事件怎么回来?
并行执行怎么隔离?
结果最后交付到哪里?
后面的几层升级,都可以沿着这组问题展开。
1. 状态存储会从 JSON 文件升级成真正的状态系统
.tasks/ 和 .scheduled_tasks.json 很适合教学演示;到了真实系统里,任务、后台作业和定时规则通常会进入数据库或更完整的持久化层,并补上:
- 权限和租户隔离
- 审计日志
- 事务或乐观锁
- 查询索引
- 失败恢复
- 和外部系统的同步能力
产品级系统不会把所有长期状态都放在几个本地文件里,而是会把不同类型的状态拆开管理。
比如 Claude Code 里的 CLAUDE.md 和 auto memory,解决的是"下一次 session 还要带着哪些规则和经验开始工作";Codex 里的 AGENTS.md、Memories 和 threads,解决的是"这条长期工作主线未来还能依赖哪些稳定上下文"。
这几个概念可以分成三类来看。
CLAUDE.md 和 AGENTS.md 都更像"项目说明书":里面通常写项目约定、常用命令、代码风格、测试方式、注意事项。它们不是某一个任务的进度,而是 Agent 每次进入这个项目时都应该知道的长期规则。
auto memory 和 Memories 更像"经验记录":比如用户偏好、常用工作流、项目里踩过的坑、某些技术选择的原因。这类信息不一定适合写进项目说明书,但又值得在后续工作里复用。
threads 则更接近"工作主线":同一个长期问题,可以在一条 thread 里持续推进。它记录的不只是规则和经验,还包括这件事本身的上下文、过程和后续动作。
它们和我们这里的 .tasks/ 不是一一对应关系,但背后的原则是一样的:
text
长期信息不能只塞进当前上下文。
2. 后台执行会从线程升级成队列、worker 和隔离环境
当前实现已经有最小的状态机和通知队列;更成熟的后台作业系统还需要处理更多运行期问题:
- 取消
- 重试
- 超时策略
- 优先级
- 并发限制
- 持久日志
- worker 隔离
- 更明确的失败分类
到这一步,BackgroundManager 就会从教学类演化成真正的 job queue。
理解产品级后台执行,不能只看系统是否支持"后台任务",更要看它如何回答四个问题:
text
谁启动它?
它在哪里跑?
运行状态怎么被观察?
结果怎么回到 Agent 面前?
Claude Code 的 background tasks 更贴近当前 session。
可以把它理解成主会话旁边开了一条后台执行通道:
text
当前 session 判断某个命令会很久
↓
把命令挂成 background task
↓
主会话继续读代码、规划或处理其他问题
↓
后台任务保留 task id 和输出
↓
后续由主会话追踪输出、读取结果、继续决策
这一类后台任务的核心,是让当前会话不中断。
后台任务虽然离开了同步工具调用链路,但它仍然服务于当前这次工作。它的结果通常还是被当前 session 拿回来继续用。
Codex 的 automations + background worktrees 是另一种形态:它不是只把命令挂到后台,而是把后台任务放进一个独立运行环境里:
text
automation 按计划或规则触发
↓
系统为它准备 background worktree
↓
任务在隔离工作区里运行
↓
运行结果进入 thread、inbox 或 triage
↓
用户或 Agent 再决定是否继续推进、合并或处理
这一类后台任务进一步关注隔离和投递:后台工作不能污染当前工作区,结果也要有明确去处。
worktree 的作用就在这里。
如果后台任务会读写代码,它就不应该随便在用户正在编辑的目录里执行。否则它可能改到一半,刚好和用户当前修改冲突。独立 worktree 的意义,就是给后台任务一个自己的工作现场:它可以跑测试、改文件、生成结果,但不会直接搅乱主工作区。
所以产品级后台执行的重点,不只是"异步",而是:
text
异步执行 + 状态可查 + 环境隔离 + 结果回流
对应到我们这份教学实现,BackgroundManager 已经做到了前两步:
- 它能异步启动命令
- 它能保存后台任务状态并把结果回流进上下文
但它还没有真正解决后两步:
- 后台任务仍然运行在同一个工作目录里
- 结果也只是注入下一轮
messages,没有更完整的 inbox、thread 或审计系统
这正是教学版和产品级系统之间的差距。
3. 调度系统会从 cron 升级成统一事件入口
当前实现主要演示 cron,但真实系统里的唤醒来源通常不只来自时间,还可能来自:
- webhook
- 外部系统事件
- 文件变化
- 队列消息
- 监控告警
- 用户重新进入线程
所以 cron 更像是"未来触发系统"的一个切面。更完整的形态通常会变成:
text
触发条件
时间 / 外部事件 / 用户回访 / 后台完成
↓
运行环境
当前工作区 / 隔离环境 / 专用 worker
↓
执行策略
权限 / 沙箱 / 审计 / hook
↓
结果交付
当前线程 / 收件箱 / 工单 / PR / 通知系统
Codex 的 automations 就更接近这个方向:它不只是"到点提醒一下",而是把计划触发、后台运行、结果投递放进一条产品化链路里。
Claude Code 这边则更像是提供一组强能力模块:background tasks、hooks、worktree isolation、外部脚本和集成。要做更完整的长期自动化,通常需要把这些能力组合起来。
cron 只是第一步。真正的工业级调度系统,最后要回答的是:
text
什么事件触发?
在哪个环境执行?
用什么权限执行?
结果交给谁?
失败以后怎么处理?
4. 真正的难点会变成"可靠性",而不只是"功能有没有"
教学版本最重要的是把主线跑通;产品级版本更关心的是长期运行时会不会稳定:
- 进程重启后能不能恢复
- 多个任务并发时会不会互相污染
- 权限边界够不够清楚
- 后台输出会不会把上下文塞爆
- 失败后有没有重试和回滚机制
- 用户能不能看懂系统当前到底在做什么
也就是说,工业化升级的重点,通常不是让 Agent"会更多功能",而是让它"长期工作时仍然可靠、可控、可恢复"。
小结
这一篇讲的三个机制,本质上是在补 Agent 长期运行的三条链路:
Persistent Tasks让状态外置:任务不再只依赖当前对话,而是有了可恢复的记录。Background Tasks让执行解耦:长动作可以先跑起来,不必一直占着主循环。Cron Scheduler让触发回流:未来的工作可以在合适的时间重新回到 Agent 面前。
这三层合在一起,Agent 才开始从"当前对话里的助手",变成一个能持续推进工作的系统。
它能记住要做什么,能等待正在发生的事,也能在未来重新接上未完成的工作。