本章在课程叙事上位于 Task System 之后,但 code.py 是从较小的 s04 Hooks 内核展开的独立机制样例,并没有携带 s10 的持久任务图。

运行完整测试、构建项目或安装依赖可能持续几分钟。同步工具调用会让 Agent Loop 一直等待命令结束,即使后续工作根本不依赖该结果。

Background Tasks 把这类命令放入后台线程和子进程:原始工具调用先返回任务 ID,父循环继续工作;命令结束后,Harness 在后续轮次把完成状态注入对话。

LLM: bash(command, run_in_background=true)

                ├── 原始 tool_result:bg_0001 已启动

                └── 后台线程 → Shell 子进程 → 完成队列

下一次模型调用前收集 <task_notification> ────┘

这解决的是“不要阻塞当前循环”,不是“后台任务会永久可靠地运行”。

后台执行必须是显式选择

当前课程在 Bash schema 中增加:

{
  "command": "pnpm test",
  "run_in_background": true
}

只有同时满足以下条件才进入后台:

tool_name == "bash" and tool_input.get("run_in_background") is True

它不再根据 installbuildtest 等关键词猜测。显式参数的好处是行为可预测:同一个命令既可以同步执行,也可以在调用者确认独立后放入后台。

“耗时”并不是唯一判断标准。适合后台执行的任务还应满足:

  • 后续工作暂时不依赖它的输出;
  • 它不会与前台操作写入同一资源;
  • 失败后可以延迟处理;
  • 用户允许该副作用持续存在;
  • 运行时能够限制和取消它。

若下一步必须读取测试结果,放到后台只会增加协调复杂度。

原始工具调用先用占位结果闭合

模型发出的每个 tool_use 都应有一个对应 tool_result。后台命令尚未完成时,Harness 不能让这次调用一直悬空,因此立即返回:

[Background task bg_0001 started]
The result will be collected on a later turn.

这个结果只证明任务已经登记且线程成功启动,不代表:

  • Shell 子进程一定创建成功;
  • 命令正在健康运行;
  • 命令最终会成功;
  • 输出已经保存;
  • 用户关闭进程后仍会继续。

产品界面应把 acceptedrunningcompletedfailed 分开显示,不能把“已启动”渲染成完成。

完成结果是新事件,不复用调用 ID

原始 tool_use_id 已经由占位结果闭合。命令完成后,课程生成独立文本通知:

<task_notification>
  <task_id>bg_0001</task_id>
  <status>completed</status>
  <command>pnpm test</command>
  <summary>10 tests passed</summary>
</task_notification>

它不是第二个 tool_result,否则一个调用会对应两次结果,破坏消息协议。通知通过后台任务 ID 与早期占位结果建立业务关联。

更成熟的系统可以把通知建模成明确事件:

{
  "type": "background_task.completed",
  "taskId": "bg_0001",
  "status": "completed",
  "exitCode": 0,
  "resultRef": "...",
  "finishedAt": "..."
}

结构化事件比拼接 XML 风格文本更容易转义、校验和展示。命令与输出都是不可信文本,若直接嵌入标签,内容中的闭合标签可能破坏边界或形成提示注入。

通知不会主动唤醒 Agent

Agent Loop 每次调用模型前执行收集:

inject_background_results(messages)
response = client.messages.create(...)

如果后台任务尚未完成,收集为空;如果已经完成,通知被加入消息,然后模型才能看到。

关键是:后台线程完成时不会主动创建新模型轮次。若父 Agent 已经返回最终文本并退出循环,结果会留在内存队列里,直到下一次用户输入触发 Agent Loop。若整个进程先退出,后台任务还可能被终止,通知也不会送达。

真正需要“完成即唤醒”的产品,应有事件循环、消息队列或调度器负责恢复对应会话,并明确通知重试、去重和过期策略。

当前 BackgroundManager 保存什么

教学实现维护三组共享状态:

self.tasks    # bg_id → command、status、原始 tool_use_id
self.results  # bg_id → 格式化输出
self._ready   # 已完成、等待收集的 bg_id

start() 在锁内递增计数器并登记任务,然后启动 daemon 线程。任务 ID 形如 bg_0001,只在当前进程内唯一;进程重启后计数从头开始。

后台线程结束时,在锁内同时更新状态、保存结果并把 ID 加入 ready 队列。collect() 也在同一把锁内取出 ready 项,并从 tasksresults 中删除。

锁保证这些内存结构不会在读写过程中互相踩踏,但它不提供:

  • 跨进程协调;
  • 跨重启持久化;
  • 任务租约或心跳;
  • exactly-once 通知;
  • 命令副作用的事务性;
  • 工作区文件写入冲突保护。

互斥锁解决的是当前 Python 进程内的数据竞态,不是分布式任务可靠性。

当前没有 check 和 cancel 工具

旧版笔记描述了 check(task_id),但当前主线没有向模型暴露任务查询或取消工具。Agent 只能:

  • 从启动占位结果得到 bg_id
  • 在后续模型轮次等待自动收集通知。

如果任务长时间运行,模型无法主动查询进度;用户也无法通过 Agent 工具精确取消某一项。

生产接口通常至少需要:

  • start_background_task
  • get_background_task
  • list_background_tasks
  • cancel_background_task
  • read_background_output

状态查询应返回结构化概要,完整日志通过分页、偏移或结果引用读取,避免每次列表都把大输出塞回上下文。

线程负责协调,真正工作在子进程

daemon 线程调用 subprocess.Popen 启动 Shell,并通过 communicate() 等待输出。Agent 主线程因此不被阻塞,但后台线程自身仍会等待子进程完成。

子进程使用新的 session,课程借此管理原始进程组:正常结束、超时、Agent 正常退出或收到 SIGTERM 时,运行时尝试依次发送 SIGTERMSIGKILL

这比只杀 Shell 父进程更完整,因为常见子命令仍在同一进程组。但它不是绝对保证:

  • 命令可以创建新的 session 或将进程脱离原组;
  • SIGKILL、主机崩溃和电源中断不会执行清理代码;
  • 外部服务、容器或远程任务可能已经脱离本地生命周期;
  • Windows 等平台需要不同的进程树管理方式。

后台执行需要明确“关闭 Agent 时是否取消任务”。当前教学实现选择尽量停止原进程组,而不是让命令成为独立持久作业。

Daemon 线程的含义

threading.Thread(target=worker, daemon=True)

daemon=True 表示这些线程不会阻止 Python 主进程退出。它不表示线程更安全,也不表示任务由系统守护。

主进程退出时,daemon 线程可能没有机会完成 finally、保存结果或发出通知。课程额外注册退出清理来停止已知 Shell 进程,但如果需要可靠完成和恢复,后台任务应交给独立 worker、系统服务或任务队列,而不是依赖进程内 daemon 线程。

超时、退出码和成功判定

课程为命令设置 120 秒超时。超时后尝试停止进程组,并把任务标记为 failed。命令退出码为 0 才进入 completed,非零退出码和 worker 异常都进入 failed。

退出码 0 仍只代表进程按其约定成功退出,不一定代表业务目标完成。例如测试脚本可能错误吞掉失败,部署命令也可能接受请求后异步失败。

后台任务结果应该包含:

  • exit code 或终止信号;
  • 开始、结束和持续时间;
  • stdout 与 stderr 的引用;
  • 是否超时、取消或被运行时终止;
  • 业务层验证结果。

不要只根据是否出现输出文本判断成功。

当前输出处理会丢失信息

子进程的 stdout 和 stderr 被 communicate() 完整读取到内存,完成后才截断到 50,000 字符。大量输出可能在截断前已经消耗大量内存。

任务完成通知只包含结果前 500 个字符。收集完成后,Manager 会删除内存中的完整结果。因此当前课程中超出通知摘要的部分没有稳定恢复入口。

生产实现更适合边运行边将输出流式写入受控文件或对象存储,并在内存中只保留固定大小的首尾摘要。还要设置:

  • 单任务输出上限;
  • 总磁盘配额;
  • stdout/stderr 分离;
  • 日志脱敏;
  • 分页或游标读取;
  • 保留期限与清理;
  • 文件权限和会话隔离。

结果引用必须在通知消费后继续有效,否则 Agent 无法复核完整失败原因。

前后台共享同一个工作区

后台 Bash 与前台工具使用相同 WORKDIR。当 Agent 一边运行测试、一边继续编辑代码时,测试看到的文件可能在执行期间发生变化,结果对应的是一个混合时间点,而不是清晰快照。

更危险的是两个后台命令同时写同一构建目录、锁文件或数据库。

因此委派前应检查资源冲突。可选策略包括:

  • 只把只读或输出目录独立的命令放后台;
  • 为任务创建独立 worktree、容器或临时目录;
  • 对共享资源使用锁;
  • 记录任务启动时的 Git revision 和工作区摘要;
  • 代码变更后把旧测试结果标记为 stale。

“命令互相独立”不能只由模型口头保证,关键资源最好由运行时显式声明和校验。

权限检查必须发生在启动之前

当前执行路径先触发 PreToolUse,再决定同步还是后台:

权限检查 → 启动后台任务 → 返回占位结果

这是正确顺序,因为后台线程一旦启动,副作用可能已经发生,之后再询问用户已经太晚。

不过 Bash 使用 shell=True 和字符串规则仍不是安全沙箱。后台任务更需要最小权限,因为它可以在用户注意力转移后继续运行。应结合容器或 OS 隔离、网络策略、凭据最小化、资源限制和审计。

当前 PostToolUse Hook 看到的是“任务已启动”的占位输出,不是最终命令结果;完成通知也不会再次经过同一个 PostToolUse 路径。若审计、错误检测或大输出处理依赖 PostToolUse,就需要为后台完成事件设计对应 Hook,而不能假设同步工具生命周期自动适用。

必须限制并发和资源

当前 Manager 没有最大后台任务数。模型可以快速创建大量线程和 Shell 进程,耗尽 CPU、内存、文件描述符或进程配额。

生产系统需要:

  • 全局和每会话并发上限;
  • 排队与背压;
  • CPU、内存、进程数和墙钟时间限制;
  • 用户与项目配额;
  • 优先级和公平性;
  • 取消传播;
  • 队列积压与失败率监控。

达到上限时应明确返回 rejected 或 queued,不能仍然声称任务 started。

建议覆盖的测试

Background Tasks 至少应验证:

  • 只有 Bash 且显式为 true 才进入后台;
  • 权限拒绝发生在子进程启动前;
  • 空命令和线程启动失败不会留下幽灵任务;
  • 占位 tool_result 与原始调用 ID 正确对应;
  • 完成通知不复用原始调用 ID;
  • 退出码 0、非零、超时、异常和取消状态;
  • 多线程更新与收集队列的互斥;
  • 通知只在后续模型调用前被收集;
  • Agent Loop 已结束时结果不会主动唤醒;
  • 进程正常退出、SIGTERM 和强制终止时的清理;
  • 子进程脱离进程组时的已知限制;
  • 大输出不会先耗尽内存,完整结果可按引用恢复;
  • 命令和输出中的标签、提示注入及敏感信息;
  • 并发上限、排队、背压和取消传播;
  • 前后台同时写工作区时的冲突与 stale 结果;
  • 后台完成事件触发独立审计 Hook。

小结

Background Tasks 通过“先返回启动占位结果,后续再注入完成事件”让 Agent Loop 不必等待慢命令。显式 run_in_background、任务 ID、锁保护的完成队列和进程组清理构成了最小实现。

但进程内 daemon 线程不是持久任务平台:它不会主动唤醒已结束的 Agent,没有查询和取消工具,也没有持久化完整输出。真实系统还必须处理并发限制、结果存储、进程树清理、工作区冲突、事件投递、权限和资源配额,并始终区分“任务已启动”与“任务已验证成功”。

参考资料