01 学习交流群
知识库之外,我们还建了「工程化专题交流群」。
进群和大家一起交流 AI 应用从 Demo 到生产落地的工程实践,包括开发流程、架构设计、效果评估、性能优化与稳定性保障,也会同步专题最新更新和实战资料。
扫码加入,一起聊聊怎样把 AI 应用真正做成、上线并稳定运行。

02 学前须知
本教程配套代码素材:https://gitee.com/geektime-geekbang_admin/ai-tutorial-material/tree/master/loop-materials
03 从和智能体对话到设计运行循环
预计阅读与练习时间:30~40 分钟
本篇目标:分清 Agent 内部的工具调用循环与 Loop Engineering 管理的多次运行,判断一个任务应该由人处理、交给一次 Agent 运行,还是设计成一个有边界的循环。
你大概做过这样的事:把任务交给编码 Agent,等它修改、测试、修错,再根据结果补一句提示。第二天又来一个相似任务,于是重复同样的交接和检查。
如果这些任务足够多,很容易产生一个诱人的想法:写个定时器,不断调用 Agent,失败就再试,直到成功。
问题也从这里开始。它怎样知道自己真的有进展?重启后从哪里继续?同一个错误出现三次时,是继续消耗 Token,还是停下来?测试被 Agent 改弱之后,“全部通过”还可信吗?遇到需要产品判断或高风险权限的任务,又由谁接手?
Loop Engineering 关心的不是怎样让循环转起来,而是怎样让它在正确的时候继续,在不该继续的时候结束。
先承认:这是一个正在形成的术语
Loop Engineering 还不是一套已经定型的行业标准。IBM 在 2026 年 7 月发布的介绍中,把它描述为设计迭代式 Agent 工作流的实践:系统围绕目标反复行动、观察和调整,而不是每一步都等待人重新提示。IBM 概念说明
一项 2026 年的探索性研究则梳理了当时的公开实践,发现较稳定的共同元素包括:触发 Agent 运行、机器可检查的停止条件、持久状态、独立验证者、Token 或时间预算,以及明确的人类升级点。作者也强调,这一领域仍缺少充分的实证研究,不能把流行说法直接当成成熟结论。探索性研究
因此,这套教程不会把某个脚本或某家产品包装成“标准答案”。我们采用一个够具体、也方便检验的工作定义:
Loop Engineering,是对多次 Agent 运行之间的触发、任务选择、状态传递、结果验证、预算、重试、隔离、停止和人工升级进行工程化设计。
这一定义里最重要的词不是“循环”,而是“多次运行之间”。
先分清四个容易混在一起的层次
同一个 Agent 工作过程里,往往同时存在 Prompt、Context、Harness 和 Loop。它们会配合,但解决的问题不同。
| 层次 | 主要问题 | 例子 |
|---|---|---|
| Prompt Engineering | 这一轮怎样把目标说清楚 | “修复搜索为空的问题,不要改数据文件” |
| Context Engineering | 当前决策前应该让模型看到什么 | 选择相关代码、错误日志和产品规则 |
| Harness Engineering | 一次运行怎样行动、观察并验证 | 文件工具、终端、浏览器、测试、权限与仓库说明 |
| Loop Engineering | 多次运行怎样接力、恢复并结束 | 触发器、任务队列、持久状态、预算、重试和升级 |
可以把 Harness 想成一名 Agent 上班时使用的工作台,把 Loop 想成安排多次轮班的调度与控制系统。工作台不好用,增加轮班次数只会更快地产生混乱;只有单次运行已经能理解项目、使用工具和读取验证结果,外部循环才有可靠基础。
这也是为什么本教程的 Issue Garden 不从一个空仓库开始。课程会提供一份具备基础说明、测试、诊断和权限边界的 ShelfCare 快照。没有学过 Harness Engineering 也能直接开始,但我们不会假设一个完全没有验证能力的项目适合自动循环。
“Agent 自己反复调用工具”还不是本教程的 Loop
现代编码 Agent 在一次任务里,本来就会多次调用模型和工具。OpenAI 当前的 Agent Runner 文档把一次 SDK run 视为一个应用层 turn:Runner 调用模型,执行工具或处理交接,再把结果送回模型,直到得到最终输出、需要审批或遇到其他真实停止点。OpenAI Agent Runner 文档
一次运行内部可能是这样:
模型规划
-> 读取文件
-> 模型决定修改
-> 写文件
-> 模型决定测试
-> 运行测试
-> 模型观察结果并修复
-> 返回最终回答
虽然这里也在循环,但它仍属于一次 Agent 运行:共享同一段会话状态,由 Agent 的 Runner 决定何时结束。
本教程要搭的是它外面的控制循环:
触发器发现任务
-> 外部 Runner 选择一个任务
-> 启动一次 Agent 运行
-> 收集退出码、Diff 和报告
-> 独立运行验证器
-> 保存本轮状态
-> 决定完成、再试、换策略、等人或停止
-> 必要时再启动下一次 Agent 运行
这两层不要混为一谈。Agent 内部工具调用次数很多,不代表它已经具备跨进程恢复、全局预算、任务去重和人工升级;外部 Runner 能重复启动 Agent,也不代表单次运行里的工具、上下文和验证已经设计好。
后面的课程统一使用两个术语:
- 一次运行(run):从外部 Runner 启动 Agent,到它返回或中止;
- 一轮(round):外部循环围绕一个任务完成“启动运行、收集结果、独立验证、保存状态和作出下一步决定”的完整过程。
一个任务可以经历多轮,每轮内部又可能包含许多次模型与工具交互。
最小可用循环必须包含什么
把 Loop Engineering 压缩到最小,至少要回答六个问题。
1. 目标:这一轮究竟要改变什么
“继续改进项目”不是目标。一个可循环处理的任务应该足够小,并且有稳定标识,例如:
issue-001:修复搜索框输入全角空格时无法匹配书名的问题
稳定 ID 用来去重和恢复;明确范围用来防止 Agent 把一次维护任务扩展成整库重写。
2. 动作:本轮允许 Agent 做什么
动作不仅是“调用模型”。还包括允许读取哪些文件、能否写代码、可以运行哪些命令、是否能访问网络,以及结果是否允许提交或发布。
Issue Garden 默认只允许修改本地实验副本、运行项目已有检查和查看 Git Diff。它不会自动合并、部署或访问真实外部服务。
3. 观察:怎样知道这一轮发生了什么
至少要保留:Agent 退出状态、修改文件、验证输出、耗时和本轮报告。只有最终一句“已完成”,无法支持下一轮判断。
4. 验证:谁来判断结果是否满足目标
Agent 可以自查,但不能把它的自述当成唯一证据。循环需要在 Agent 运行结束后,由外部 Runner 执行受保护的测试、规则检查和 Diff 审查。
5. 调整:失败后为什么值得再来一轮
重试应该有新的信息或新的策略,例如补充缺失日志、缩小任务,或用干净上下文重新开始。如果输入、上下文和做法都不变,只是再次发送同一句提示词,那不叫纠偏,只是在碰运气。
6. 停止:什么时候不再启动下一轮
至少要包括:任务验证通过、达到最大轮次、耗尽时间或费用预算、连续出现相同失败、缺少必要信息、触发权限边界,以及人主动取消。
把它们连起来,才是一条完整控制链:
目标 -> 动作 -> 观察 -> 验证 -> 调整 -> 继续或停止
其中“人工处理”不是循环失败,而是一种正常终态。工程化系统的可靠性,常常体现在它知道什么时候不应该继续自动做。
为什么 while true 不够
下面这段伪代码确实会重复调用 Agent:
while True:
result = run_agent("继续修复项目,直到测试通过")
if tests_pass():
break
它没有回答几个关键问题:
- 进程重启后,之前尝试了什么;
tests_pass()检查的是可信基线,还是 Agent 刚刚改弱的测试;- Agent 连续三次犯同一个错误时如何停止;
- 一轮修改了 80 个文件时是否仍可接受;
- 测试本身不稳定时,怎样区分环境错误与逻辑错误;
- 任务需要产品判断或额外权限时,由谁接手;
- 两个 Runner 同时拿到同一任务时,怎样防止重复修改;
- 已经成功的副作用会不会在重试中执行第二次。
真正的工程工作大多发生在循环体之外:契约、状态、验证、锁、预算、审批和恢复。循环语法只负责重复。
同样,给上面代码加一个 max_retries = 5 也只解决了“最多转几次”,没有解决每次为什么值得转、结果是否可信,以及停止后留下什么证据。
哪些任务值得循环化
一个任务满足下面四个条件时,才更像合适的 Loop 候选:
- 会重复出现。 不是只发生一次的临时工作;
- 每轮可以缩小。 能拆成短小、相对独立的任务;
- 结果可以验证。 有测试、规则、结构化检查或清楚的人工审核点;
- 失败可以控制。 修改可隔离、可恢复,不会一次错误就产生不可逆后果。
少一个条件,不代表绝对不能自动化,但需要更强的人类控制。例如“每天整理低风险依赖更新”很适合形成循环;“自动决定并执行生产数据库删除”即便反复出现,也不该因为频率高就交给无人值守 Agent。
可以用这个简化判断顺序:
任务是否重复?
否 -> 人或一次 Agent 运行通常足够
是
↓
能否拆成有明确结束条件的小任务?
否 -> 先重写任务,不要急着循环
是
↓
是否有独立验证和可控失败面?
否 -> 先补 Harness 与审核点
是 -> 值得设计一个有预算的 Loop
先做一次任务分类练习
暂时不要写代码。先为下面六个场景选择“人处理”“一次 Agent 运行”或“工程化 Loop”。最好先自己判断,再看后面的解释。
| 场景 | 你的选择 |
|---|---|
| 把一个函数改名,并修复所有引用 | |
| 每晚扫描本地任务队列,处理一个有测试的小缺陷 | |
| 决定是否删除生产环境中的过期客户数据 | |
| 第一次为个人网站重写首页文案 | |
| 每周检查依赖更新,测试通过后生成待审核分支 | |
| 需求只有“让产品更好用”,没有验收标准 |
一种合理答案是:
- 函数改名和首次文案任务通常交给一次 Agent 运行,人最终检查即可;
- 每晚处理小缺陷、每周准备依赖更新具备重复、小范围和可验证特征,适合 Loop;
- 删除生产客户数据需要高风险业务与合规判断,应由人控制,Agent 最多提供清单和建议;
- “让产品更好用”连一个可执行任务都还不是,应该先由人澄清目标,而不是让循环不断自行发明工作。
这里没有按任务名字机械分类。“依赖更新”也可能涉及破坏性迁移,“文案修改”也可能形成大量低风险、可审阅的重复任务。真正决定方案的是边界、验证和失败代价。
贯穿项目 Issue Garden 怎样工作
后面的课程使用 Issue Garden:给 ShelfCare 增加一个本地维护循环。ShelfCare 是个人藏书管理小应用,已经具备基础项目说明、检查命令和权限边界;Issue Garden 则负责组织多次 Agent 运行。
最终结构大致如下:
本地任务队列
-> 触发器发现待处理任务
-> Runner 获取锁并选择一项
-> 读取 loop.yaml 和持久状态
-> Agent 适配器启动一次运行
-> 外部验证器检查测试、Diff 和任务契约
-> 事件日志与状态文件记录证据
-> 决策器输出:
completed / retry / needs_input / blocked / stopped
为了让每位读者都能先验证控制逻辑,课程最初使用一个确定性的模拟 Agent。它不会真的写复杂代码,却能稳定重现“成功”“信息不足”和“越过权限”三种路径。单轮 Runner 跑通后,再把适配器换成你已经安装的命令行 Agent。
这不是回避真实 Agent。恰恰相反:如果循环只有连接某个在线模型时才能测试,我们就很难知道错误来自 Agent、网络,还是控制逻辑。先把 Loop 自身变成可重复验证的程序,再接入不确定的执行者,排错会简单得多。
最终综合实战会提供三类任务:
- 测试可以明确验证的小缺陷,循环应完成并产出候选修改;
- 缺少关键信息的模糊需求,循环应停止并请求补充;
- 超出权限边界的任务,循环应拒绝继续并说明触发了哪条规则。
结果只进入本地待审核区,不自动合并到 main,更不会部署。
写下你的第一张 Loop 候选卡
现在选一个你真的会重复处理的任务,先写成一张候选卡。可以放在课程笔记中,不需要提交到任何项目。
# Loop 候选卡
- 任务名称:
- 触发方式:手动 / 定时 / 事件
- 输入从哪里来:
- 每轮最多处理什么:
- 可以自动执行的动作:
- 必须人工批准的动作:
- 独立验证方式:
- 成功条件:
- 立即停止条件:
- 最大轮次或时间:
- 失败后怎样恢复:
- 为什么它比一次 Agent 运行更合适:
用 Issue Garden 填写时,关键条目可以是:
任务名称:本地小缺陷维护
触发方式:先手动,后续再增加定时或文件事件
输入:tasks/inbox/ 中一条结构化任务
每轮范围:只处理一个任务,不超过约定修改文件数
自动动作:读取仓库、修改实验分支、运行本地验证
人工批准:外部网络、扩大权限、合并、发布、破坏性命令
独立验证:任务验收 + npm run check + Diff 规则
成功条件:所有受保护验证通过并生成候选结果
停止条件:预算耗尽、相同失败重复、信息不足、越权或人工取消
检查你的候选卡。如果“独立验证方式”只能写成“让 Agent 自己判断”,或者“立即停止条件”完全为空,就先不要实现循环。下一篇会把这些文字升级成机器可读取、启动前就能检查的契约。
常见误解
已经有定时任务,算不算 Loop Engineering
定时器只回答“何时启动”。如果每次启动后不读取持久状态、不验证结果,也没有预算、停止和人工升级,它只是重复执行器。触发机制是 Loop 的一部分,不是全部。
Agent 自己会测试和修错,还需要外部 Loop 吗
一次任务能在一个运行中完成,就不需要为了概念完整再包一层 Loop。外部循环适合跨运行、跨时间或跨任务的工作,并负责单次 Agent 不适合拥有的全局状态和控制权。
只要设置最大重试次数就安全了吗
不是。最大轮次能限制一种资源消耗,却不能防止错误副作用、弱化测试、重复处理、读取敏感信息或多个任务相互覆盖。后面的课程会分别处理这些边界。
没有做过 Harness Engineering 能继续吗
可以。课程会提供具备基本 Harness 的项目快照。你仍然需要理解一个原则:Loop 会放大单次运行的能力,也会放大单次运行的缺陷。若验证、权限和运行入口本身不可靠,应该先修 Harness,而不是增加重试。
所有任务最终都应该自动闭环吗
不应该。产品判断、合规审批、高风险外部操作和目标本身不清楚的任务,都应该保留人类决策。一个能明确输出 needs_input 或 blocked 的循环,比假装什么都能自动解决的循环更可靠。
本篇留下了什么
到这里,你应该能画出两层循环:一次 Agent 运行内部的模型—工具循环,以及外部 Runner 管理多次运行的控制循环。你也有了一张 Loop 候选卡,里面至少写清任务、验证、预算和停止条件。
下一篇,我们会把 Issue Garden 的这些边界写进 loop.yaml。在第一次调用 Agent 之前,Runner 就应该能拒绝缺少验证器、没有预算或不存在停止条件的配置——先让循环拥有契约,再让它开始转动。
04 先为循环写一份可执行契约
预计阅读与练习时间:45~60 分钟
本篇目标:建立 Issue Garden 实验副本,读懂并验证loop.yaml,让缺少预算、验证器或停止条件的循环在第一次调用 Agent 之前就被拒绝。
上一篇,我们用 Loop 候选卡回答了“这个任务值不值得循环化”。但卡片仍然只是给人看的说明。真正开始运行后,如果每一轮都靠提示词临时重申边界,迟早会漏掉一句。
这一篇先不调用 Agent。我们要把任务队列、单轮范围、可写路径、验证器、预算和停止条件写进一份机器可检查的契约。这样,后面的 Runner 不是“尽量遵守约定”,而是在契约无效时根本不启动。
准备独立实验副本
课程提供的起始快照位于:
assets/issue-garden-starter/
它同时包含两部分:一个很小的 ShelfCare 搜索模块,以及我们即将搭建的 Loop 控制面。请从课程根目录复制它,不要直接修改起始快照。
macOS 或 Linux:
cp -R assets/issue-garden-starter issue-garden-lab
cd issue-garden-lab
Windows PowerShell:
Copy-Item -Recurse -Force assets/issue-garden-starter issue-garden-lab
Set-Location issue-garden-lab
确认当前位置和关键文件。macOS、Linux 运行:
pwd
python3 --version
node --version
git --version
Windows PowerShell 运行:
Get-Location
py -3.11 --version
node --version
git --version
你应该看到 loop.yaml、requirements-loop.txt、src/、test/ 和 tools/,Python 版本至少为 3.11,Node.js 至少为 20。具体小版本不必和示例相同。
如果 py -3.11 不存在,但 python --version 显示 3.11 或更高,后续把 Windows 命令里的 py -3.11 换成 python 即可。若 Node 或 Git 命令不存在,先补齐课程 Brief 约定的环境;它们分别用于运行被维护项目的验证器和检查真实 Diff,不能用 Agent 的口头报告代替。
建立 Python 虚拟环境
loop.yaml 使用 YAML,是因为它比纯 JSON 更适合人手工审阅。Python 标准库不解析 YAML,因此项目把唯一的 Python 依赖锁定在 requirements-loop.txt 中。
macOS 或 Linux:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-loop.txt
Windows PowerShell:
py -3.11 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -r requirements-loop.txt
成功后,下面的命令应该打印 PyYAML 的版本号:
python -c "import yaml; print(yaml.__version__)"
如果 PowerShell 拒绝执行激活脚本,不必修改整台电脑的执行策略。可以直接使用虚拟环境里的解释器:
.\.venv\Scripts\python.exe -m pip install -r requirements-loop.txt
.\.venv\Scripts\python.exe -c "import yaml; print(yaml.__version__)"
此后把文中的 python 换成 .\.venv\Scripts\python.exe 即可。若安装阶段提示无法连接软件源,先检查网络或公司的 Python 软件源配置;不要从不明网站下载同名安装包。
保存一个干净基线
后面我们会用 Git 判断 Agent 究竟改了什么。现在先建立本地仓库和基线提交:
git init
git add .
git commit -m "chore: start issue garden lab"
git status --short
最后一条命令没有输出,表示工作区与基线一致。这个仓库只在本地使用,不需要添加远程地址。
如果 Git 因作者身份为空而拒绝提交,只为当前实验仓库设置一个占位身份,再重试提交:
git config user.name "Issue Garden Learner"
git config user.email "learner@example.invalid"
git commit -m "chore: start issue garden lab"
这里没有使用 --global,不会改动你其他仓库的身份配置。
契约与 Prompt 解决的是两件事
Prompt 会告诉一次 Agent 运行“这次要做什么”,例如:
修复全角空格导致图书搜索失败的问题,只修改 src/,完成后运行测试。
loop.yaml 则约束多次运行共同遵守的控制规则:一次最多跑多久、最多尝试几轮、哪些路径永远不能写、谁负责验证、出现什么情况必须结束。
两者的区别可以压缩成一句话:
Prompt 是本轮工作说明,循环契约是 Runner 的准入条件和裁决依据。
因此,不要把 API Key、Cookie 或一次任务的长篇背景写进 loop.yaml。它会进入版本控制,适合保存稳定、可审阅的规则,不适合保存秘密和易变的会话内容。
读懂 loop.yaml 的八组边界
先打开实验目录里的 loop.yaml。你会看到八个顶层部分。
1. schema_version:先声明解释方式
schema_version: 1
配置会随课程演进。显式版本号让 Runner 遇到未知格式时停止,而不是猜测新字段的含义。当前验证器只接受整数 1。
2. project:所有相对路径从哪里计算
project:
root: .
. 表示实验仓库根目录。验证器拒绝绝对路径和包含 .. 的路径,避免一个错误配置把队列或状态写到项目之外。
这仍然不等于操作系统级沙箱。路径检查是第一层防误操作,后面还会加入 Git 工作区隔离和命令边界。
3. queue:任务在不同终态之间怎样移动
queue:
inbox: tasks/inbox
processing: tasks/processing
completed: tasks/done
needs_input: tasks/needs-input
blocked: tasks/blocked
任务不会只有“未完成”和“完成”两种状态:
inbox是尚未领取的任务;processing表示某个 Runner 已经领取;completed保存验证通过的任务;needs_input表示缺少必要信息,等待人补充;blocked表示触碰权限或其他不可自动跨越的边界。
把后两种状态分开很重要。缺少验收样例可以补充后重开;要求读取真实密钥则不是“多试一次”能解决的问题。
4. runner:一轮允许占用多久
runner:
agent_adapter: mock
max_rounds_per_task: 3
round_timeout_seconds: 600
cooldown_seconds: 2
课程先使用 mock 适配器,让同一输入得到可重复结果。max_rounds_per_task 限制一个任务最多经历三轮;每轮超过 600 秒就终止;两轮之间短暂停顿,避免失败后立即高速重启。
注意,cooldown_seconds 不是失败策略。它只能降低重启频率,不能说明下一轮为何值得执行。第 6 篇会为重试增加“必须有新证据或新计划”的条件。
5. budgets:把“别消耗太多”改成可计算上限
budgets:
max_agent_runs_per_task: 3
max_wall_seconds_per_task: 1800
max_changed_files_per_round: 8
max_diff_lines_per_round: 200
这四个数字分别限制 Agent 运行次数、任务总耗时、单轮修改文件数和单轮 Diff 行数。
它们不是普适最佳值,只是适合这个小项目的保守起点。真实项目应根据任务粒度和失败代价调整。一个两行缺陷突然改动 60 个文件,即使测试通过,也更像任务失控而不是效率很高。
课程不把 Token 数作为唯一预算,因为不同 CLI 是否暴露精确用量并不一致。运行次数、墙钟时间和改动规模是 Runner 一定能够观察到的本地指标;第 8 篇再把可获得的费用或 Token 信息作为可选预算加入。
6. permissions:白名单与保护区同时存在
permissions:
allowed_write_paths:
- src/
protected_paths:
- test/
- acceptance/
- tasks/
- .git/
- .env
- loop.yaml
- .loop/
Agent 在本课程里只能修改 src/。测试、验收样例、任务本身、Git 元数据、潜在密钥、循环契约和 Runner 状态都属于保护区。
为什么既要白名单,又要保护名单?白名单回答“通常允许写哪里”,保护名单强调“即使以后扩大白名单,这些地方仍需额外审查”。后面的验证器会以真实 Diff 为准,而不是相信 Agent 声称自己只改了 src/。
7. verification:成功必须有外部证据
verification:
require_clean_baseline: true
baseline_commands:
- [npm, test]
- [npm, run, check]
allow_task_verification_command: true
allow_test_changes: false
命令故意写成参数列表,而不是一整段 Shell 字符串。Runner 后续会直接传递参数,不经 Shell 展开,从而减少 &&、重定向和命令替换带来的意外执行面。
require_clean_baseline 要求处理任务前,项目自身的基础检查已经通过;否则 Loop 无法区分旧故障与本轮新故障。任务还可以附带一条额外验收命令,但必须经过允许列表检查。Agent 不得通过修改测试让结果“变绿”。
参数列表只减少 Shell 解析风险,并不会自动让任意程序变安全。哪些可执行文件和参数可以进入验证器,仍要由 Runner 审核,第 5 篇会真正实现这道门。
8. stop 与 idempotency:结束和去重必须提前定义
stop:
terminal_statuses:
- completed
- needs_input
- blocked
- stopped
same_failure_limit: 2
stop_on:
- budget_exhausted
- timeout
- permission_boundary
- missing_information
- human_cancelled
idempotency:
task_id_field: id
claim_dir: .loop/claims
result_dir: .loop/results
terminal_statuses 告诉调度器哪些结果不应自动开始下一轮;same_failure_limit 规定相同失败连续出现两次就停止原地重试。预算耗尽、超时、越权、缺少信息和人工取消都是明确停止原因。
幂等性配置则要求每个任务有稳定 id,并为领取记录和最终结果指定持久目录。以后即使进程重启,Runner 也可以判断任务是否已被领取或完成,避免同一任务被重复处理。
仅仅创建一个结果文件还不能保证所有副作用幂等。例如重复发送邮件仍可能发出两次。因此本课程默认没有外部写入;第 4 篇会先解决本地状态恢复,再讨论哪些动作根本不该自动重放。
先让机器拒绝不完整契约
现在在 issue-garden-lab/ 根目录运行:
python tools/validate_contract.py
预期输出:
[OK] loop.yaml 契约有效
这条命令会检查必填项、正整数预算、安全相对路径、验证命令结构、四种终态和五类停止原因。退出码为 0,后续 Runner 才可以继续。
课程还准备了一份故意损坏的契约:它把最大轮数设成 0,并删掉了所有基础验证命令。运行:
python tools/validate_contract.py examples/loop.invalid.yaml
预期会看到类似输出,且进程退出码为 2:
[INVALID] examples/loop.invalid.yaml 有 2 个问题:
- runner.max_rounds_per_task 必须是大于等于 1 的整数
- verification.baseline_commands 至少要有一条命令
输出行顺序以实际版本为准。关键不是报错文案,而是一个没有轮次上限或没有验证器的配置无法进入运行阶段。
macOS 或 Linux 可立即查看上一条命令的退出码:
echo $?
Windows PowerShell 使用:
$LASTEXITCODE
应得到 2。这就是“可执行契约”与说明文档的差别:它不仅写下规则,还能阻止无效状态继续传播。
用自动化测试检查验证器本身
契约验证器也可能写错,所以不能只拿一份正确配置跑一次。执行:
python -m unittest discover -s loop_tests -p "test_*.py" -v
预期有四项测试通过:有效契约被接受、零轮次与空验证器被拒绝、越出项目的 ../ 路径被拒绝、Windows 绝对路径被拒绝。结尾类似:
Ran 4 tests in ...s
OK
然后确认契约里写下的两条基础命令在当前机器上确实可执行:
npm test
npm run check
第一条应显示两项搜索测试通过;第二条通常没有正文输出,只要退出码为 0 就表示 JavaScript 语法检查通过。
最后检查工作区:
git status --short
它仍应没有输出。虚拟环境、Python 缓存和未来的 .loop/ 状态已经被 .gitignore 排除;验证过程不应悄悄改动课程基线。
这一组检查形成了完整闭环:
写下契约
-> 正确配置通过
-> 错误配置被拒绝并返回非零退出码
-> 单元测试验证拒绝规则
-> 契约声明的项目检查真实运行
-> Git 证明没有额外修改
改配置时先问三个问题
以后把这份契约迁移到自己的项目,不要先复制数字。先回答:
- Runner 能否直接观察这个限制? “质量要高”无法计算,“最多改 8 个文件”可以计算。
- 触发边界时应该发生什么? 是整轮失败、转人工审核,还是允许经过批准后继续?
- 证据由谁产生? 如果成功条件仍然只是 Agent 输出一句
done,配置再详细也没有形成独立验证。
还有一条容易忽略:契约验证通过,只证明配置结构和最低安全要求成立,不证明任务一定正确,也不证明操作系统权限已经隔离。它是启动前的门卫,不是整个安全系统。
常见阻塞
YAML 报缩进或类型错误
YAML 使用空格表达层级,不要混用 Tab。列表项前的 - 也要和同级元素对齐。若你加入类似 yes、no、on、off 的字符串,建议加引号,避免不同 YAML 解析规则把它们当成布尔值。
先单独运行 python tools/validate_contract.py,不要让 Runner 在启动后才发现语法问题。
正确配置被提示路径不安全
课程只接受项目内相对路径,不接受 /tmp/tasks、C:\tasks 或 ../tasks。如果真实系统必须使用外部目录,不应该简单删掉检查;应在设计阶段明确挂载点、访问权限和清理策略,再扩展契约与验证器。
npm test 失败,但契约验证通过
这两个检查回答不同问题。契约验证只检查 Loop 规则,npm test 检查被维护项目的基线。如果基线已失败,先修复环境或项目,不要让循环把旧错误当成 Agent 本轮造成的问题。
本篇留下了什么
现在,issue-garden-lab/ 已经是一个带 Git 基线的独立实验项目。loop.yaml 明确了任务队列、单轮上限、资源预算、可写路径、受保护区域、外部验证、终态和幂等性目录;验证器能接受完整契约,也能在运行前拒绝危险缺口。
下一篇,我们会实现第一个单轮 Runner。它仍然只使用确定性的模拟 Agent,但会真正完成一次“领取任务—启动运行—收集结果—验证—输出决策”的流程。到那时,loop.yaml 不再只是被单独检查的文件,而会开始控制程序行为。
05 实现第一个单轮运行器
预计阅读与练习时间:50~70 分钟
本篇目标:让 Issue Garden 完整运行一轮——检查基线、领取一个任务、调用确定性模拟 Agent、观察真实 Diff、执行验证,并输出completed或明确停止原因。
上一篇结束时,我们已经有了有效的 loop.yaml,但还没有程序根据它做决定。现在要补上第一个 Runner。
这版 Runner 故意只跑一轮。它不会自动重试,也不会连接真实编码 Agent。这样我们可以先看清一轮里哪些动作属于控制面、哪些结果必须由外部观察,而不被模型输出、网络和多轮状态同时干扰。
把本篇素材加入实验副本
起点应是第 2 篇留下的 issue-garden-lab/:虚拟环境已经安装 PyYAML,Git 已有一个干净的基线提交。若你跳过了上一篇,请先按上一篇的方法复制 assets/issue-garden-starter/、安装依赖并提交基线。
回到课程根目录,把本篇增量素材覆盖复制进实验副本。
macOS 或 Linux:
cp -R assets/lesson-03-runner/. issue-garden-lab/
cd issue-garden-lab
source .venv/bin/activate
Windows PowerShell:
Copy-Item -Recurse -Force assets/lesson-03-runner/* issue-garden-lab
Set-Location issue-garden-lab
.\.venv\Scripts\Activate.ps1
新增内容包括:
issue_garden/single_round.py 单轮控制逻辑和模拟适配器
tools/run_once.py 命令行入口
tasks/inbox/issue-001.json 第一项待处理任务
loop_tests/test_single_round.py 命令边界与路径边界测试
先运行控制面测试:
python -m unittest discover -s loop_tests -p "test_*.py" -v
你应该看到第 2 篇的四项契约测试和本篇的四项单轮测试,共八项通过。新增测试特别检查了:任务可以调用指定的 Node 验收测试,&& 拼接命令会被拒绝,../ 越界路径会被拒绝,修改受保护测试文件会被报告。
确认测试通过后,把本篇文件保存为新的课程基线:
git add .
git commit -m "feat: add single-round runner"
git status --short
最后一条命令应没有输出。这样,接下来看到的 Diff 就只来自本轮运行,不会混入刚复制的课程文件。
先确认任务确实没有完成
打开 tasks/inbox/issue-001.json,任务内容可以概括为:当搜索词里出现全角空格时,ShelfCare 仍能匹配不带空格的中文书名;Agent 只允许修改 src/。
其中最值得注意的是验收命令:
"verification_command": [
"node",
"--test",
"acceptance/fullwidth-space.test.mjs"
]
它是参数数组,不是一段交给 Shell 解释的文本。当前 Runner 只接受 node --test acceptance/*.mjs 这种固定形状,像 && curl ...、项目外路径或其他可执行程序都会在领取任务前被拒绝。
先手工运行验收测试:
node --test acceptance/fullwidth-space.test.mjs
此时测试应该失败,核心差异是实际结果为空数组 [],预期结果包含《深入理解计算机系统》。这是我们故意保留的任务缺陷,不是环境安装失败。
再运行项目基础检查:
npm test
npm run check
这两条应该通过。它们与失败的任务验收并不矛盾:基础检查证明当前项目没有已知回归,任务验收则定义本轮要新增的行为。Runner 必须分清“基线本来就坏了”和“新需求尚未满足”。
一轮不是“一次调用 Agent”
tools/run_once.py 的核心入口是 run_one_round()。它依次完成八个动作:
验证 loop.yaml
-> 运行干净基线检查
-> 从 inbox 选择文件名排序最前的任务
-> 检查任务及验收命令
-> 把任务原子移动到 processing
-> 调用 Agent 适配器
-> 观察 Git Diff 并运行外部验证
-> 作出决策并移动已完成任务
这也是“运行(run)”和“一轮(round)”的实际区别:Agent 调用只是其中一步。一轮还包含调用前的准入检查,以及调用后的观察、验证和裁决。
为什么先检查基线,再领取任务
如果 npm test 在开始前就失败,Runner 会输出:
{
"decision": "stopped",
"reason": "dirty_baseline"
}
任务仍留在 inbox,Agent 不会启动。否则一轮结束时,Runner 无法判断失败是旧问题还是本次修改引入的。
为什么领取使用文件移动
Runner 把:
tasks/inbox/issue-001.json
移动为:
tasks/processing/issue-001.json
在同一个本地文件系统中,重命名比“先复制、再删除”更接近一次原子状态转换:另一个 Runner 不会在中间看到两份同名待办。它还不是完整的并发锁;第 7 篇会加入调度与隔离。目前请只启动一个 Runner。
为什么先用确定性模拟 Agent
agent_adapter 目前是 mock。它只认识 fix_fullwidth_space 场景,并执行一项固定修改:用 Unicode NFKC 统一字符宽度,再移除检索字符串中的空白。
模拟适配器有两个作用:
- 同一输入总得到同一改动,便于验证控制逻辑;
- 它不需要账号、API Key 或网络,失败来源只有本地代码和 Runner。
它仍遵守适配器边界:接收结构化任务,返回 status 与 summary。以后接入真实 CLI Agent,只替换“怎样产生候选改动”,任务领取、Diff 观察、验证和决策不应全部重写。
为避免覆盖读者已有修改,模拟 Agent 会先比对 src/search.mjs 是否仍是课程基线。只要文件内容不同,它就停止,而不是强行写回固定答案。
真正运行第一轮
确认当前目录是 issue-garden-lab/,执行:
python tools/run_once.py
成功时会输出一份较长的 JSON 报告。下面只保留关键字段,实际报告还会包含每条命令的输出和耗时:
{
"decision": "completed",
"reason": "verified",
"task_id": "issue-001",
"agent": {
"status": "changed",
"summary": "已用 NFKC 统一字符宽度并移除检索空白;只修改 src/search.mjs"
},
"changes": {
"files": ["src/search.mjs"],
"lines": 9
},
"boundary_errors": [],
"verification": [
{"command": ["npm", "test"], "exit_code": 0},
{"command": ["npm", "run", "check"], "exit_code": 0},
{
"command": ["node", "--test", "acceptance/fullwidth-space.test.mjs"],
"exit_code": 0
}
],
"recovery_note": null
}
只有下面四个条件同时成立,决策才是 completed:
- 模拟 Agent 确实产生了候选修改;
- 基础检查和任务验收全部以
0退出; - Git Diff 没有离开
src/,也没有触碰保护路径; - 修改文件数和 Diff 行数没有超过
loop.yaml的预算。
Agent 的 summary 不参与成功裁决。即使它声称“已修复”,只要验收失败或真实 Diff 越界,Runner 就不会完成任务。
Windows 上为什么也能直接运行 npm
Runner 会先用系统路径查找命令的实际可执行文件,再通过 subprocess 以参数数组调用,且设置 shell=False。这既兼容 Windows 常见的 npm.cmd,也避免把任务内容交给 Shell 展开。
这不代表任意参数都安全。可执行文件和任务验收路径仍必须通过允许规则;使用参数数组只是减少一层注入面。
别只相信 Runner 的报告
现在独立检查它留下的结果。
先确认任务已从待办箱进入完成区。macOS 或 Linux:
ls tasks/inbox
ls tasks/done
Windows PowerShell:
Get-ChildItem tasks/inbox
Get-ChildItem tasks/done
inbox 应为空,done 中应出现 issue-001.json。
重新运行三类验证:
node --test acceptance/fullwidth-space.test.mjs
npm test
npm run check
现在任务验收应由失败变为通过,原有两项搜索测试仍通过,语法检查退出码仍为 0。这证明修复了新增行为,同时没有破坏原有行为。
最后直接查看改动,而不是只读 Agent 摘要:
git diff -- src/search.mjs
git status --short
Diff 应只显示 src/search.mjs 增加 normalizeText() 并在查询与候选书目上使用它。状态列表还会显示任务从 tasks/inbox/ 移到 tasks/done/;这是 Runner 自己管理的队列状态,不属于 Agent 的代码修改。
代码 Diff 和任务移动暂时都没有提交。课程默认把结果留给人审阅,不会替你合并或发布。
验证“没有任务”也会结束
再次运行同一命令:
python tools/run_once.py
这次应输出:
{
"decision": "stopped",
"reason": "no_task"
}
命令退出码为 2,表示没有完成新任务。no_task 不是异常事故,而是“不应继续启动 Agent”的正常停止原因。一个可靠循环不应该在队列为空时发明工作。
macOS 或 Linux 用 echo $?、PowerShell 用 $LASTEXITCODE,可以确认退出码。第二次运行不会再次改写 src/search.mjs,也不会复制一份完成记录。
当前版本故意没有解决什么
这个 Runner 已经形成了单轮闭环,但还不能用来无人值守运行真实 Agent。
- 报告只打印到终端。 进程退出后,没有结构化历史供下一轮恢复;
- 失败任务会留在
tasks/processing/。 当前版本宁可停住,也不冒险自动重放; - 队列移动没有独立锁。 两个进程同时启动的竞争条件尚未完整处理;
- Git 观察针对已跟踪代码 Diff。 现在的模拟 Agent只修改一个已跟踪文件;接入任意真实 Agent 前,还要处理新文件、删除、重命名和工作区隔离;
- 验证逻辑与 Runner 在同一模块。 第 5 篇会把生成者和验证者拆开,避免执行者影响裁决规则。
这些不是让读者自己猜着补的遗漏,而是后续课程要逐步解决的问题。现在的边界很明确:它只用于本地、单进程、确定性实验。
常见阻塞与恢复
输出 dirty_baseline
先手工运行报告里的基础命令。若 npm test 或 npm run check 失败,修复环境或还原无关改动,再启动 Runner。不要为了让循环继续而关闭基线检查。
模拟 Agent 提示课程基线不同
先查看:
git diff -- src/search.mjs
如果这些是你希望保留的改动,不要还原,改用一份新的实验副本。如果只是重复练习产生的修改,可以先把任务文件移回 inbox,再还原源文件。
macOS 或 Linux:
mv tasks/done/issue-001.json tasks/inbox/issue-001.json
git restore src/search.mjs
Windows PowerShell:
Move-Item tasks/done/issue-001.json tasks/inbox/issue-001.json
git restore src/search.mjs
若失败任务在 tasks/processing/,把上面的 done 换成 processing。git restore 会丢弃该文件尚未提交的修改,所以一定先看 Diff;不确定时,重新复制实验目录更稳妥。
输出 verification_command_not_allowed
检查任务里的命令是否恰好是三个参数:node、--test、acceptance/ 下的 .mjs 文件。不要通过放宽到“允许任意字符串命令”来绕过错误。真实项目应建立自己的验证命令注册表,而不是允许任务正文决定执行什么程序。
本篇留下了什么
Issue Garden 已经完成第一轮真实控制流程:有效契约和干净基线允许任务进入处理区,模拟 Agent 产生可预测修改,Runner 从 Git 和进程退出码中收集观察,外部验证通过后才把任务标记为 completed;队列为空时,下一次运行会明确停止。
下一篇,我们会处理这版 Runner 最明显的缺口:终端输出不是记忆,processing 里的半成品也不会自己解释发生过什么。我们将建立 .loop/ 状态脊柱,把任务事实、每轮尝试、验证证据和最终决策分别持久化,让循环在进程中断后仍然知道从哪里继续。
修订后的边界验收
超时输出统一转为文本,退出码为 124。stdout、stderr 有无内容都必须能写入 JSON。修订版在 POSIX 终止整个命令进程组;Windows 使用 taskkill /T,仍需在目标 Windows 环境验证进程树清理。
06 加入状态脊柱
预计阅读与练习时间:55~75 分钟
本篇目标:把任务事实、当前阶段、事件历史、单轮证据和最终结果写入.loop/,在任务领取后中断进程,再从一个明确的安全检查点恢复并完成任务。
第 3 篇的 Runner 已经能完成一轮,但它的记忆主要留在终端。只要进程在任务进入 processing 后退出,下次启动就只能看到一个“处理中”的文件,却不知道 Agent 是否已经开始、代码是否已改、验证是否跑过。
这一篇要建立状态脊柱(state spine):不把聊天记录当记忆,也不靠人回忆上一轮,而是把每次控制决策写成机器可读、可核对的本地状态。
这类做法也出现在长时运行 Agent 的工程实践中:把进度、测试结果和下一步显式留在工作区,让新的运行能够从外部事实继续,而不是依赖越来越长的会话。Anthropic 的长时运行 Agent Harness 实践
先保存第 3 篇的成果
起点是已经完成 issue-001 的 issue-garden-lab/。此时 src/search.mjs 已支持全角空格,任务文件位于 tasks/done/,但这些结果还没有提交。
先独立确认上一项任务仍然通过:
node --test acceptance/fullwidth-space.test.mjs
npm test
npm run check
三项都通过后,保存这个基线:
git add src/search.mjs tasks
git commit -m "fix: normalize search whitespace"
git status --short
最后一条命令应没有输出。若你还没有完成第 3 篇,不要直接复制本篇素材;issue-002 会沿用上一轮加入的 normalizeText(),缺少这个基线时模拟 Agent 会主动停止。
加入状态层和第二项任务
从课程根目录复制本篇增量素材。
macOS 或 Linux:
cp -R assets/lesson-04-state/. issue-garden-lab/
cd issue-garden-lab
source .venv/bin/activate
Windows PowerShell:
Copy-Item -Recurse -Force assets/lesson-04-state/* issue-garden-lab
Set-Location issue-garden-lab
.\.venv\Scripts\Activate.ps1
本篇新增了三个关键部件:
issue_garden/state_store.py 原子写入、事件追加和状态目录
issue_garden/stateful_round.py 领取、恢复和带证据的单轮流程
tasks/inbox/issue-002.json 作者名标点归一化任务
tools/run_once.py 也被替换成带状态的入口,并增加一个只用于教学的受控中断参数。
先运行所有控制面测试:
python -m unittest discover -s loop_tests -p "test_*.py" -v
预期共十项通过:前两篇的八项检查仍在,本篇又验证了“相同 JSON 内容不因键顺序不同而改变摘要”和“状态文件更新后仍是完整 JSON,不残留临时文件”。
将本篇代码和任务保存成干净基线:
git add .
git commit -m "feat: add recoverable loop state"
git status --short
.loop/ 已在 .gitignore 中,它不会进入提交。课程代码和任务定义属于项目版本,某次运行产生的领取记录、尝试报告和结果则属于运行时状态。
状态脊柱不是一份越来越大的聊天记录
Issue Garden 会为每个任务建立下面的结构:
.loop/
├── claims/
│ └── issue-002.json
├── results/
│ └── issue-002.json
└── tasks/
└── issue-002/
├── facts.json
├── state.json
├── events.jsonl
└── attempts/
└── round-001.json
这些文件分工不同。
| 文件 | 保存什么 | 为什么单独放 |
|---|---|---|
facts.json |
原始任务快照、任务摘要、契约摘要、起始 Git HEAD | 记录本轮开始时不可随意改写的事实 |
state.json |
当前 status、phase、轮次和更新时间 |
让 Runner 快速判断从哪个阶段继续 |
events.jsonl |
领取、恢复、Agent 启动、证据保存等追加事件 | 保留发生顺序,不用反复改写整段历史 |
attempts/round-001.json |
本轮 Agent 报告、真实 Diff、命令退出码与输出 | 把执行者说法与验证证据放在同一轮记录中,但字段分开 |
results/issue-002.json |
最终决策的完整快照 | 调度器和人无需重放所有事件就能读取结果 |
Agent 的 summary 是执行者提供的解释,不是事实来源;命令退出码、Git Diff 和任务原文才是 Runner 能独立观察的证据。把它们拆开,下一轮才不会把“Agent 认为已经完成”误当成“验证已经通过”。
先制造一次可控中断
issue-002 要求作者名查询忽略中英文标点差异。先确认它尚未满足:
node --test acceptance/punctuation-search.test.mjs
测试应失败:书中作者是 Randal Bryant,查询使用 Randal,Bryant,当前归一化只移除空白,不移除标点。
现在运行带中断参数的 Runner:
python tools/run_once.py --simulate-interruption-after-claim
预期输出:
{
"decision": "interrupted",
"reason": "controlled_after_claim",
"task_id": "issue-002",
"checkpoint": ".loop/tasks/issue-002/state.json"
}
退出码是 75,表示本次没有完成任务,而是在明确的检查点主动结束。这个参数不会杀死操作系统进程;它用于稳定复现“任务已领取、Agent 尚未启动”的中断状态。
此时检查队列。macOS 或 Linux:
ls tasks/inbox
ls tasks/processing
Windows PowerShell:
Get-ChildItem tasks/inbox
Get-ChildItem tasks/processing
issue-002.json 应位于 processing,inbox 中不再有同名文件。
再查看状态:
python -m json.tool .loop/tasks/issue-002/state.json
关键字段应类似:
{
"task_id": "issue-002",
"status": "active",
"phase": "claimed",
"round": 1,
"updated_at": "...Z"
}
active + claimed 的含义非常具体:Runner 已经拥有这个任务,但还没有记录 agent_started。这是一个可以自动恢复的安全边界。
最后确认代码没有被修改:
git diff -- src/search.mjs
命令应没有输出。到这里形成一个完整检查:任务已经离开待办箱,磁盘上已有领取与状态记录,但 Agent 还没有产生代码副作用。
领取本身也需要分阶段
如果 Runner 先移动任务,后写 checkpoint,那么它恰好在两步之间崩溃时,就会留下没有解释的 processing 文件。新版的领取顺序是:
写入 facts 与 claim
-> state.phase = claiming
-> 把任务移动到 processing
-> state.phase = claimed
state.json 不是直接覆盖。atomic_write_json() 会先在同一目录写临时文件、刷新到磁盘,再用 os.replace() 替换正式文件。这样,中断更可能留下旧的完整状态或新的完整状态,而不是只写了一半的 JSON。
events.jsonl 则采用追加写入,每行是一个独立 JSON 事件。查看它:
macOS 或 Linux:
cat .loop/tasks/issue-002/events.jsonl
Windows PowerShell:
Get-Content .loop/tasks/issue-002/events.jsonl
你应该至少看到 claim_started、claim_completed 和 controlled_interruption。精确时间不同,但顺序应一致。
原子替换只保护“单个状态文件不会半写”,并不让多个文件形成数据库事务。任务移动、事件追加和状态更新之间仍可能中断,因此恢复代码必须识别阶段,而不能只判断“文件存在”。
从安全检查点恢复
不移动任何文件,也不要重新创建任务。直接再次运行:
python tools/run_once.py
Runner 会先发现 tasks/processing/issue-002.json,然后依次核对:
.loop/claims/issue-002.json、facts.json和state.json是否都存在;- 当前任务内容的 SHA-256 摘要是否与领取时一致;
- 文件名是否与领取记录一致;
- 状态是否确实为
active + claimed。
全部满足后,它追加一个 resumed 事件,再启动模拟 Agent。成功报告的关键字段如下:
{
"decision": "completed",
"reason": "verified",
"task_id": "issue-002",
"round": 1,
"resumed": true,
"agent": {
"status": "changed"
},
"changes": {
"files": ["src/search.mjs"],
"lines": 2
},
"boundary_errors": []
}
resumed: true 证明本轮使用了已有领取状态,而不是把任务当成新任务再领一次。模拟 Agent 把归一化规则从“移除空白”扩展为“移除空白和 Unicode 标点”,仍只修改 src/search.mjs。
从三处独立核对完成结果
第一处是用户可见行为:
node --test acceptance/punctuation-search.test.mjs
node --test acceptance/fullwidth-space.test.mjs
npm test
npm run check
新旧验收与基础检查都应通过。新能力没有破坏第 3 篇已经完成的全角空格行为。
第二处是任务队列。macOS 或 Linux:
ls tasks/processing
ls tasks/done
Windows PowerShell:
Get-ChildItem tasks/processing
Get-ChildItem tasks/done
processing 应为空,done 中同时有 issue-001.json 与 issue-002.json。
第三处是持久状态:
python -m json.tool .loop/tasks/issue-002/state.json
python -m json.tool .loop/results/issue-002.json
python -m json.tool .loop/tasks/issue-002/attempts/round-001.json
state.json 应为 status: completed、phase: finished;结果文件应包含最终 decision: completed;尝试文件应保存 resumed: true、Agent 说明、两行 Diff 规模,以及三条验证命令各自的退出码和输出。
最后查看真实改动:
git diff -- src/search.mjs
git status --short
.loop/ 不应出现在状态列表中,因为它是被忽略的运行数据。你仍会看到源文件修改和任务从 inbox 移到 done,课程不会替你自动提交这些候选结果。
哪些阶段不能自动重放
当前恢复器只自动接受 phase: claimed。如果状态是 agent_running 或 agent_finished,它会返回 blocked / unsafe_resume,而不是再次调用 Agent。
这是保守但重要的选择。中断发生在 Agent 启动以后时,我们不知道它是否已经:
- 写了一半代码;
- 创建了未被旧版 Git 检查发现的新文件;
- 调用了外部工具;
- 完成了动作,但来不及写回状态。
盲目重放可能把一次动作执行两次。持久状态能减少不确定性,却不能凭空提供“恰好一次”语义。只要任务包含发消息、付款、发布、删除远端数据等外部副作用,就需要目标系统提供幂等键、事务或人工确认;不能仅凭本地 state.json 宣称安全。
在本课程中,所有默认任务只修改本地实验副本。第 6 篇会让重复失败变成显式升级,第 7 篇再用工作区隔离缩小不确定状态的影响面。
常见阻塞
unsafe_resume:缺少 checkpoint
如果 tasks/processing/ 有任务,但 .loop/claims/、facts.json 或 state.json 不完整,Runner 会拒绝猜测。先查看 Git Diff、任务文件和现有 .loop/ 内容,判断 Agent 是否曾启动;不要为了继续而随手把任务移回 inbox。
实验做乱且没有要保留的结果时,最清楚的恢复方式是重新复制一份课程实验目录,而不是伪造状态。
任务摘要不一致
领取以后不要直接编辑 tasks/processing/issue-002.json。Runner 会比较规范化 JSON 的 SHA-256 摘要,内容变化就停止。需要修正任务时,应先让当前运行进入人工终态,再以新任务 ID 提交新版本;复用 ID 会破坏去重依据。
重做练习时提示已有领取记录
即使你用 Git 还原了代码和 tasks/,.loop/ 默认不会随 Git 变化。如果只是想保留旧记录后重做,可以先把整个状态目录改名:
macOS 或 Linux:
mv .loop ../issue-garden-loop-previous
Windows PowerShell:
Move-Item .loop ../issue-garden-loop-previous
然后确认源代码和任务队列已回到本篇开始前的状态。不要只删除某个 claim,否则事实、事件和结果会互相矛盾。
本地日志能否作为合规审计证据
不能直接等同。当前使用者同时拥有项目和 .loop/ 的写权限,本地 JSONL 可以被编辑。它足以教学、调试和恢复低风险本地任务;需要防篡改审计时,应把事件发送到权限隔离的外部存储,并记录身份、签名和保留策略。
本篇留下了什么
Issue Garden 现在不只会运行,还会留下可恢复的状态:稳定任务 ID 和摘要防止换单,facts.json 保存起点,state.json 标记阶段,events.jsonl 保存顺序,attempts/ 保存每轮证据,results/ 保存终态。你已经在 claimed 检查点中断一次,并在不重新领取任务的情况下恢复完成。
下一篇,我们会处理另一个信任问题:目前 Agent 调用、Git 边界检查和命令验证仍集中在同一个控制模块。我们将把生成者与验证者拆开,让 Agent 只能提交候选改动,由独立验证器根据受保护规则裁决结果。
修订后的边界验收
所有任务入口限制 ID 为 1~80 位字母数字、下划线和连字符,不能包含路径分隔符。恢复时同时比对任务、契约摘要和 Git HEAD;任一变化都拒绝自动恢复,需核查后以新任务/新领取继续。
07 把生成者和验证者分开
预计阅读与练习时间:55~75 分钟
本篇目标:把“产生候选改动”和“裁决是否完成”拆成两个边界,用一项过度自信但没有改代码的结果证明 Agent 自述不能通关,再让真实候选改动通过独立验证。
前两篇的 Runner 已经会检查 Git Diff 和测试,但生成逻辑、验证逻辑和调度仍挤在同一模块里。代码能工作,不代表职责已经清楚:如果任务自己带来一条验证命令,或生成者能够在验证前改写验收文件,那么“测试通过”可能只是规则也被一起改了。
这一篇把流程拆成三部分:
Orchestrator:领取任务、推进状态、根据验证报告移动队列
Generator:只负责产生候选改动和一份不受信任的说明
Verifier:提前锁定验证策略,观察真实工作区并独立裁决
核心原则很简单:生成者可以提出“我完成了”,但只有验证者可以把任务判定为 completed。
保存第 4 篇留下的结果
起点是完成 issue-002 后的 issue-garden-lab/。先确认全角空格、标点归一化和基础检查仍然通过:
node --test acceptance/fullwidth-space.test.mjs
node --test acceptance/punctuation-search.test.mjs
npm test
npm run check
全部通过后,把第 4 篇的候选结果保存为新基线:
git add src/search.mjs tasks
git commit -m "fix: ignore punctuation in search"
git status --short
最后一条命令应没有输出。新版 Orchestrator 会在领取任务前拒绝 tasks/ 之外的未提交改动,避免把上一轮残留误算成这一轮候选。
加入独立验证器和两项对照任务
从课程根目录复制本篇增量素材。
macOS 或 Linux:
cp -R assets/lesson-05-verifier/. issue-garden-lab/
cd issue-garden-lab
source .venv/bin/activate
Windows PowerShell:
Copy-Item -Recurse -Force assets/lesson-05-verifier/* issue-garden-lab
Set-Location issue-garden-lab
.\.venv\Scripts\Activate.ps1
新增结构如下:
issue_garden/generator.py 候选改动生成器
issue_garden/verifier.py 工作区观察和验证策略执行器
issue_garden/orchestrator.py 只编排,不替任何一方声称成功
verification/registry.json 受保护的验证配置注册表
tasks/inbox/issue-003.json 自称成功但不改代码的对照任务
tasks/inbox/issue-004.json 真正加入 ISBN 搜索的任务
acceptance/isbn-search.test.mjs 受保护的任务验收
loop.yaml 也有两处变化:verification/ 被加入保护路径,allow_task_verification_command 改为 false。任务不再直接决定运行哪个程序,只能引用预先登记的验证配置。
先运行控制面测试:
python -m unittest discover -s loop_tests -p "test_*.py" -v
预期十三项通过。本篇新增的三项测试检查 Git 状态解析:修改、新文件、删除和重命名都能被识别;领取任务造成的既有队列移动不会被误算成 Generator 的候选代码。
保存课程代码和任务定义:
git add .
git commit -m "feat: separate generator and verifier"
git status --short
应得到干净工作区。这样,后面每个任务的候选 Diff 都从同一个可说明的 Git HEAD 开始。
任务只选择验证配置,不提供任意命令
打开 tasks/inbox/issue-004.json,你会看到:
{
"id": "issue-004",
"title": "允许按 ISBN 搜索藏书",
"scenario": "add_isbn_search",
"instructions": "把可选 ISBN 字段加入搜索文本,只允许修改 src/ 下的实现。",
"verification_profile": "isbn-search"
}
任务只声明 verification_profile。真正的命令保存在已提交的 verification/registry.json:
{
"isbn-search": {
"commands": [
["npm", "test"],
["npm", "run", "check"],
["node", "--test", "acceptance/isbn-search.test.mjs"]
]
}
}
这么做有三个直接好处:
- 任务输入不能把
curl、重定向或 Shell 拼接偷偷塞进验证阶段; - 验收命令可以经过代码审查并跟项目一起版本化;
- 多个同类任务可以复用同一验证配置,而不是复制一段容易漂移的命令。
这不是说所有项目都必须使用 JSON 注册表。你也可以把验证入口放在固定脚本、CI Job 或构建系统中。关键是验证规则由控制面拥有,任务只能选择受信任入口,不能临时发明执行权限。
先确认 ISBN 行为尚未实现:
node --test acceptance/isbn-search.test.mjs
测试应失败。书目里虽然有 978-7-111-54493-7,当前搜索文本只包含标题和作者,查询 9787111544937 得不到结果。
验证器必须在生成之前锁定规则
Orchestrator 在启动 Generator 之前会完成三件事:
- 从注册表读取
isbn-search的命令,并复制到内存; - 记录当前 Git 状态,区分领取任务造成的队列变化与后续候选变化;
- 计算
test/、acceptance/、verification/和loop.yaml的文件摘要。
Generator 返回以后,Verifier 不会重新相信任务或生成者提供的命令,而是使用事先锁定的参数数组。它还会比较验证资产摘要,并读取 git status --porcelain -z,识别:
- 已修改和已删除的跟踪文件;
- 新建但尚未加入 Git 的文件;
- 重命名前后的两个路径;
- 修改文件数和 Diff 行数;
- 白名单外路径与受保护路径。
这比只运行 git diff --name-only 多走一步,因为普通 Diff 不包含未跟踪文件。一个生成者即使没有修改现有测试,也可能新建脚本或生成物;验证者必须观察完整候选表面。
先拒绝一份“只有口头成功”的结果
文件名排序最前的是 issue-003.json。它使用一个故意过度自信的模拟 Generator:不修改任何文件,却返回“已经完成,所有测试都应该通过”。运行:
python tools/run_once.py
预期退出码为 2,关键报告如下。命令输出会比示例更长:
{
"decision": "blocked",
"reason": "independent_verification_rejected",
"task_id": "issue-003",
"generation": {
"status": "claimed_success",
"summary": "我已经完成 ISBN 搜索支持,所有测试都应该通过。"
},
"verification": {
"accepted": false,
"changed_files": [],
"changed_lines": 0,
"boundary_errors": [
"生成者没有产生候选文件改动"
]
}
}
验证报告中,基础测试和语法检查仍会通过,但 ISBN 验收退出码是 1。Generator 的语气再肯定,也不能覆盖两项外部事实:没有候选 Diff,新增验收仍失败。
检查队列。macOS 或 Linux:
ls tasks/blocked
ls tasks/processing
Windows PowerShell:
Get-ChildItem tasks/blocked
Get-ChildItem tasks/processing
issue-003.json 应进入 blocked,processing 应为空。再检查:
git diff -- src/search.mjs
node --test acceptance/isbn-search.test.mjs
python -m json.tool .loop/results/issue-003.json
源文件没有变化,验收仍失败,结果文件记录 decision: blocked。失败在这里不是坏事:系统拒绝把没有证据的自述伪装成完成。
再接受一份有证据的候选改动
现在待办箱中只剩 issue-004。再次运行同一个入口:
python tools/run_once.py
这次 Generator 会把可选 isbn 字段加入搜索文本。Verifier 从自己锁定的注册表运行三条命令,并根据真实工作区产生类似报告:
{
"decision": "completed",
"reason": "independent_verification_passed",
"task_id": "issue-004",
"generation": {
"status": "candidate_ready",
"summary": "已把可选 ISBN 字段加入搜索文本;只修改 src/search.mjs"
},
"verification": {
"accepted": true,
"changed_files": ["src/search.mjs"],
"changed_lines": 2,
"boundary_errors": []
}
}
这里,Generator 的 candidate_ready 仍然不是终态。真正让 Orchestrator 输出 completed 的,是 verification.accepted: true:一项白名单内的小改动,三条验证命令全部以 0 退出,验证资产没有变化。
从行为、Diff 和持久记录三处复核
先运行用户可见行为:
node --test acceptance/isbn-search.test.mjs
node --test acceptance/fullwidth-space.test.mjs
node --test acceptance/punctuation-search.test.mjs
npm test
npm run check
所有检查都应通过。ISBN 查询支持新增能力,前两篇完成的空格与标点行为仍保留。
查看真实改动:
git diff -- src/search.mjs
git status --short
src/search.mjs 只应把 book.isbn ?? "" 加入待归一化的搜索文本。状态列表还会显示 issue-003 从 inbox 移到 blocked、issue-004 从 inbox 移到 done;.loop/ 仍不会出现在 Git 状态中。
查看两份最终裁决:
python -m json.tool .loop/results/issue-003.json
python -m json.tool .loop/results/issue-004.json
第一份应是 blocked,包含失败验收和“没有候选改动”的边界错误;第二份应是 completed,包含实际 Diff 与成功验证。它们使用同一个 Orchestrator,差别来自证据,不来自 Generator 的自我评价。
确认结果无误后,保存这一阶段的项目基线,为第 6 篇的多轮重试做准备:
git add src/search.mjs tasks
git commit -m "feat: search books by ISBN"
git status --short
应再次得到干净工作区。
模块分开不等于权限已经隔离
目前 Generator 和 Verifier 是不同 Python 模块,但仍运行在同一个用户权限和同一个项目目录中。这种拆分已经改善了设计:策略归属清楚、报告可单测、生成者说明不参与裁决;但它还不是强安全边界。
如果把任意不受信任程序当作 Generator,它理论上仍可能尝试修改验证器代码、Git 元数据或进程能访问的其他文件。当前 Verifier 会检测工作区里持续存在的验证资产变化,却无法防止所有“修改后又恢复”的进程内攻击。
因此,真实系统通常还需要至少一层执行隔离:独立用户、容器、受限沙箱、只读挂载或一次性工作区。第 7 篇会先加入 Git 工作区隔离;高风险场景仍应使用操作系统或基础设施提供的权限边界。
同样,使用另一个模型充当“验证 Agent”可以补充主观审查,但它仍可能和生成 Agent 犯相关错误。能由测试、类型检查、规则、Diff 和资源上限判定的条件,优先交给确定性工具;模型评审适合处理这些工具覆盖不了的语义问题,而不应替代它们。
常见阻塞
输出 dirty_worktree
新版 Orchestrator 不接受 tasks/ 之外的未提交改动。先运行:
git status --short
git diff
确认这些变化属于上一轮、你自己的工作,还是意外残留。需要保留就提交或换新实验副本;不要为了继续而把干净检查关掉。
输出 invalid_verification_profile
任务引用了注册表中不存在的名字,或注册表命令不是非空字符串数组。先核对 verification/registry.json,不要把任务里的任意命令重新开放。新增验证配置应和测试一起评审、提交,再让任务引用它。
为什么 issue-003 直接进入 blocked,没有重试
本篇只负责建立独立裁决。没有代码改动、验收仍失败时,原样再调用一次生成器没有新信息,只会重复消耗。第 6 篇会区分可重试失败、需要重新规划的失败和应该立即升级给人的失败。
验证命令通过,但仍被拒绝
查看 boundary_errors。测试通过只证明行为检查满足,不代表改动范围合规。修改了 acceptance/、verification/、测试、契约或白名单外文件,即使所有命令为绿色,也应拒绝候选结果。
本篇留下了什么
Issue Garden 现在有了清楚的信任分工:Generator 只能产生候选改动和不受信任的说明;Verifier 在生成前锁定注册策略与验证资产,生成后观察包括新建、删除和重命名在内的工作区变化,并运行独立命令;Orchestrator 只根据验证报告决定 completed 或 blocked。
下一篇,我们会让失败不再只有“拒绝并结束”。循环将根据失败签名判断:这次是否值得带着新证据重试,是否必须生成新计划,还是已经连续撞上同一个问题、应该停止并升级给人。
修订后的边界验收
验证资产现在包含 package.json、锁文件、scripts、tools 和 issue_garden。边界检查失败立即返回,不执行候选修改后的验证命令;先试改 package.json 并放入哨兵副作用,确认该命令完全未执行。
08 设计重试重新规划和失败升级
预计阅读与练习时间:60~80 分钟
本篇目标:让失败进入可解释的控制分支——第一轮失败后保存证据并生成新计划,第二轮用新策略完成;连续出现相同失败时停止消耗,把任务转入needs-input。
第 5 篇证明了“Generator 说完成”不等于完成。接下来还有一个更现实的问题:Verifier 拒绝候选以后,应该立刻结束,还是再给 Agent 一轮机会?
答案不能只是“失败就重试三次”。如果下一轮的输入、证据和策略都不变,所谓重试只是重复下注。可靠的循环至少要分清三种动作:
- Retry(重试):故障更像暂时性环境问题,计划不变,但等待后再执行;
- Replan(重新规划):验证暴露了计划本身的问题,下一轮必须带着新证据换做法;
- Escalate(升级):缺少信息、越过权限、预算耗尽或重复撞上同一失败,停止自动运行并找人。
这一篇用两个任务把后两条路径跑一遍。网络限流、临时服务错误等 Retry 场景会说明设计方法,但不会为了演示去调用真实外部服务。
从干净的 ISBN 搜索基线开始
第 5 篇结束时,issue-003 已进入 blocked,issue-004 已完成,ISBN 搜索结果已经提交。先确认工作区和既有能力:
git status --short
node --test acceptance/isbn-search.test.mjs
npm test
npm run check
Git 状态应为空,其他命令应通过。若 src/ 仍有未提交改动,先回到第 5 篇末尾完成审阅和提交;多轮引擎不会在来源不明的候选上继续叠加修改。
加入多轮引擎与两项练习任务
从课程根目录复制本篇增量素材。
macOS 或 Linux:
cp -R assets/lesson-06-retry/. issue-garden-lab/
cd issue-garden-lab
source .venv/bin/activate
Windows PowerShell:
Copy-Item -Recurse -Force assets/lesson-06-retry/* issue-garden-lab
Set-Location issue-garden-lab
.\.venv\Scripts\Activate.ps1
关键新增内容:
issue_garden/loop_engine.py 多轮决策、失败签名和升级
issue_garden/loop_generator.py 能读取轮次与新计划的模拟 Generator
tools/run_loop.py 多轮入口
tasks/inbox/issue-005.json 第一轮失败、第二轮完成
tasks/inbox/issue-006.json 连续相同失败后请求输入
acceptance/diacritic-search.test.mjs 重音符号验收
acceptance/publisher-search.test.mjs 出版社字段验收
验证注册表也新增了 diacritic-search 和 publisher-search 两个 profile。
先运行控制面测试:
python -m unittest discover -s loop_tests -p "test_*.py" -v
预期十六项通过。本篇新增三项检查:失败签名忽略输出文字和耗时抖动,退出码变化会产生新签名,只统计末尾连续出现的相同失败。
保存课程代码与任务定义:
git add .
git commit -m "feat: add retry and escalation policy"
git status --short
工作区应再次干净。
先给失败做分类,再决定下一步
多轮引擎不会看到非零退出码就机械重跑,而是按证据选择动作。
| 失败类型 | 典型证据 | 默认动作 |
|---|---|---|
| 暂时性环境故障 | 超时、限流、临时 5xx,代码没有变化 | 等待并 Retry,但仍受时间与次数预算限制 |
| 计划或实现错误 | 验收稳定失败,日志提供了新的定位信息 | 把失败证据写进新计划,再 Replan |
| 候选越界 | 修改测试、契约、白名单外文件或改动过大 | 立即 Block,不靠下一轮“自觉改好” |
| 信息不足 | 字段约定、期望行为或输入样例缺失 | needs-input,等人补充 |
| 相同失败重复 | 连续多轮的失败签名相同 | 停止原地打转并升级 |
| 预算耗尽 | 达到最大轮次、运行次数、时间或费用 | 停止;是否加预算由人决定 |
Retry 和 Replan 最容易混淆。判断标准不是“再运行一次”,而是下一轮有没有新的决策依据:
- 临时网络错误消失后,同一计划可能成功,适合 Retry;
- 测试稳定证明方案不成立时,必须把失败变成新上下文,适合 Replan;
- 如果什么都没变,只是再次发送同一句提示词,它既不是可靠 Retry,也不是 Replan。
失败签名只保留稳定特征
命令输出常含耗时、临时路径和执行顺序。直接对整份日志求哈希,即使根因相同,每轮也可能得到不同值。
Issue Garden 的失败签名只包含:
排序后的 boundary_errors
+ 每条验证命令的参数数组
+ 每条命令的退出码
它不包含完整 stdout;只提取其中稳定的失败测试标识和错误类别、stderr、耗时和 Agent 的措辞。序列化后计算 SHA-256,并取前 16 位作为可读标识。
这不是通用的“根因识别算法”。两个不同问题可能恰好拥有同样的命令退出码;一个问题也可能在不同阶段产生不同签名。它的作用更朴素:发现控制面能够稳定观察到的“没有进展”模式,避免把日志噪声误认为新情况。
loop.yaml 中已有:
stop:
same_failure_limit: 2
所以同一签名连续出现两次,就停止自动尝试。
第一轮失败后,先暂停看新计划
issue-005 要求输入 garcia marquez 时也能匹配 García Márquez。当前使用 NFKC 统一字符宽度,但不会移除重音标记。
先确认验收失败:
node --test acceptance/diacritic-search.test.mjs
然后让循环只完成第一轮并在 Replan 后暂停:
python tools/run_loop.py --pause-after-round 1
预期退出码为 75,输出类似:
{
"decision": "interrupted",
"reason": "paused_after_replan",
"task_id": "issue-005",
"completed_round": 1,
"next_round": 2,
"plan": ".loop/tasks/issue-005/plan-round-002.json",
"failure_signature": "..."
}
第一轮模拟 Generator 错误地认为 NFKC 已经足够,因此没有修改代码。Verifier 观察到两件事:候选文件为空,重音符号验收退出码为 1。Loop 没有立即重发原任务,而是把这两项证据写进下一轮计划。
查看第一轮记录和第二轮计划:
python -m json.tool .loop/tasks/issue-005/attempts/round-001.json
python -m json.tool .loop/tasks/issue-005/plan-round-002.json
python -m json.tool .loop/tasks/issue-005/state.json
你应该看到:
- 第一轮
decision是retry,原因是verification_failed_replan; - 尝试记录包含一个失败签名和
consecutive_same_failure: 1; - 新计划的
new_evidence保存边界错误与失败命令,不复制大段易变日志; - 状态是
active + round_ready,下一轮编号为2。
再查看源文件:
git diff -- src/search.mjs
应没有输出。第一轮没有留下失败候选,因此第二轮可以从已提交基线继续。若失败轮已经修改代码,多轮引擎会输出 rejected_candidate_requires_cleanup 并转入 blocked;在第 7 篇加入隔离工作区之前,它不会用自动 git restore 冒险覆盖未知改动。
用新证据完成第二轮
不用移动任务,也不要手工修改计划。直接运行:
python tools/run_loop.py
引擎发现 processing/issue-005.json 与 round_ready checkpoint,读取 plan-round-002.json 后继续。第二轮 Generator 根据上一轮证据改用 NFKD 分解字符,并移除 Unicode Mark 类字符。
关键输出应类似:
{
"decision": "completed",
"reason": "independent_verification_passed",
"task_id": "issue-005",
"round": 2,
"generation": {
"status": "candidate_ready",
"summary": "根据上一轮失败证据改用 NFKD,并移除 Unicode Mark 类字符。"
},
"verification": {
"accepted": true,
"changed_files": ["src/search.mjs"],
"changed_lines": 2,
"boundary_errors": []
}
}
独立核对新旧行为:
node --test acceptance/diacritic-search.test.mjs
node --test acceptance/fullwidth-space.test.mjs
node --test acceptance/punctuation-search.test.mjs
node --test acceptance/isbn-search.test.mjs
npm test
npm run check
全部应通过。再确认任务进入完成区,并且两轮记录都存在:
macOS 或 Linux:
ls tasks/done
ls .loop/tasks/issue-005/attempts
Windows PowerShell:
Get-ChildItem tasks/done
Get-ChildItem .loop/tasks/issue-005/attempts
你应看到 issue-005.json、round-001.json 和 round-002.json。第一轮留下失败证据,第二轮留下成功证据,没有用最终结果覆盖历史。
审阅 Diff 后保存新基线:
git diff -- src/search.mjs
git add src/search.mjs tasks
git commit -m "feat: ignore diacritics in search"
git status --short
最后一条命令应为空。提交后再处理下一项任务,避免把成功候选混进另一个循环。
连续相同失败时,停止而不是继续加轮次
现在待办箱中只剩 issue-006。它用于稳定复现一个“没有新信息”的情况:模拟 Generator 每轮都表示出版社字段约定不清,没有产生候选修改;Publisher 验收每轮都以相同方式失败。
运行:
python tools/run_loop.py
这次不加暂停参数,引擎会在一次进程中完成两轮。预期退出码为 2,最终关键字段为:
{
"decision": "needs_input",
"reason": "same_failure_limit_reached",
"task_id": "issue-006",
"round": 2,
"failure_signature": "...",
"consecutive_same_failure": 2,
"request": "请补充字段约定、验收示例或新的诊断证据后,以新任务继续。"
}
第一轮已经写入失败证据和新计划,但第二轮仍得到相同的边界错误、命令与退出码,因此签名不变。系统不再启动第三轮,即使契约的最大轮数是 3。max_rounds_per_task 是绝对上限,不是必须用完的额度。
检查终态。macOS 或 Linux:
ls tasks/needs-input
ls .loop/tasks/issue-006/attempts
Windows PowerShell:
Get-ChildItem tasks/needs-input
Get-ChildItem .loop/tasks/issue-006/attempts
issue-006.json 应进入 needs-input,尝试目录有 round-001.json 与 round-002.json。分别打开两份文件:
python -m json.tool .loop/tasks/issue-006/attempts/round-001.json
python -m json.tool .loop/tasks/issue-006/attempts/round-002.json
python -m json.tool .loop/results/issue-006.json
两轮的 failure_signature 应相同,连续计数从 1 变为 2;最终结果不是含糊的 failed,而是带具体补充请求的 needs_input。
源代码不应产生新 Diff:
git diff -- src/search.mjs
命令没有输出,说明升级给人没有顺手制造一个半成品。
什么时候值得 Retry,而不是 Replan
本篇没有连接真实服务,但实际项目经常遇到暂时性故障。设计 Retry 时至少加四个限制:
- 只对明确的暂时性错误重试。 例如受控列表中的超时、限流或服务不可用;语法错误和验收失败不是网络 Retry。
- 使用退避与抖动。 不要在服务故障时立即高速重放;同时仍受任务总时间约束。
- 确认动作可重放。 读取和本地纯计算通常安全,发送消息或创建订单必须有幂等键。
- 把等待也计入预算。 Retry 不应绕开总时长、运行次数和费用上限。
如果一次失败带来了新的测试输出、诊断文件或范围信息,就把这些证据提炼进下一轮计划,走 Replan。若证据没有变化,签名计数应让循环尽早停下。
常见阻塞
输出 dirty_worktree
多轮引擎只容忍 tasks/ 的队列移动,src/ 或验证资产有未提交改动就停止。运行:
git status --short
git diff
先审阅并处理上一项候选。不要在多个任务之间共享一团未提交代码;第 7 篇会用独立工作区降低这种耦合。
输出 rejected_candidate_requires_cleanup
Verifier 拒绝了一个已经修改文件的候选。当前版本不自动还原,因为它无法证明工作区里没有人的并行修改。查看尝试记录和 Diff,决定保留到人工分支、手工修复,还是在确认无价值后回到基线。
为什么第二轮一定要有计划文件
loop_generator.py 在第二轮找不到 new_evidence 时会停止。这个限制用于防止“轮次增加了,输入却没变”的伪 Replan。真实 Agent 的下一轮 Prompt 也应明确引用上一轮失败证据、变化后的假设和禁止重复的做法。
补充信息后能否把 issue-006 原样移回 inbox
不建议。原任务 ID 已有 claim、两轮记录和终态。补充字段约定后创建一个新 ID,并在新任务中引用 issue-006,才能保留历史并避免去重冲突。
本篇留下了什么
Issue Garden 现在会根据失败证据决定下一步:稳定签名排除日志噪声,Replan 把失败命令和边界错误写进下一轮计划,round_ready checkpoint 支持暂停后恢复;同一失败连续达到阈值时,任务进入 needs-input,而不是耗尽所有轮次。
下一篇,我们会把这个已经能多轮运行的循环从“手工启动、共用一个工作区”推进到“可触发、可调度、可隔离”:加入单实例锁、手动/定时/事件触发边界,以及每个任务独立的 Git 工作区。
修订后的边界验收
失败签名保留命令、退出码、边界错误和 Node 测试输出中的失败测试标识/错误类别。不会直接把全部日志放入签名;适配其他测试工具时需添加其结构化错误提取器。测试同退出码但不同断言、同一断言仅耗时变化两组样本。
09 加入触发调度和隔离
预计阅读与练习时间:60~80 分钟
本篇目标:让同一个 Dispatcher 接受手动、定时和事件三类触发,用单实例锁阻止重叠运行,并在独立 Git worktree 中生成候选,证明主工作区不会被 Agent 的代码改动污染。
前六篇一直由我们手工运行命令,而且 Generator 直接修改 issue-garden-lab/。这便于学习控制逻辑,但不适合长期运行:定时器和文件事件可能同时到达,两次调度会争抢同一任务;失败候选还可能把主工作区留在半成品状态。
这一篇把三个问题分开处理:
Trigger:为什么现在值得检查队列
Scheduler:同一时刻由谁取得运行权
Workspace:候选改动发生在哪里
触发器只负责“敲门”,不能绕过任务契约;调度器一次最多领取一项任务;候选代码在独立 worktree 中生成并验证,只把 patch 和报告送回主工作区供人审核。
先把第 6 篇的终态保存下来
第 6 篇结束时,issue-005 已完成并提交,issue-006 因连续相同失败进入 tasks/needs-input/,但这次队列移动尚未提交。先确认主代码没有额外 Diff:
git diff -- src/search.mjs
git status --short
源代码 Diff 应为空,状态列表只应包含 issue-006 的队列移动。保存这个控制面终态:
git add tasks
git commit -m "chore: record publisher task needs input"
git status --short
最后一条命令应没有输出。创建 worktree 时,Git 会从当前 HEAD 复制一个独立工作目录;如果该提交没有包含前面课程的成果,隔离区也不会凭空拥有它们。
加入 Dispatcher、锁和隔离 Worker
从课程根目录复制本篇增量素材。
macOS 或 Linux:
cp -R assets/lesson-07-dispatch/. issue-garden-lab/
cd issue-garden-lab
source .venv/bin/activate
Windows PowerShell:
Copy-Item -Recurse -Force assets/lesson-07-dispatch/* issue-garden-lab
Set-Location issue-garden-lab
.\.venv\Scripts\Activate.ps1
新增部件:
tools/dispatch.py 统一触发入口
issue_garden/scheduler.py 单实例目录锁与事件校验
issue_garden/dispatcher.py 选任务、建 worktree、收集候选
issue_garden/isolated_worker.py 只在隔离目录运行 Generator 和 Verifier
tasks/inbox/issue-007.json 图书标签搜索任务
events/inbox/issue-007.json 受限事件示例
acceptance/tag-search.test.mjs 标签搜索验收
先运行控制面测试:
python -m unittest discover -s loop_tests -p "test_*.py" -v
预期十八项通过。本篇新增两项:第二个调度器无法取得已被占用的目录锁;事件入口只接受 task.created,不会把任意事件类型当作执行指令。
提交课程代码和任务定义:
git add .
git commit -m "feat: add triggers and isolated dispatch"
git status --short
工作区应为空。后面创建的锁、worktree、候选 patch 和报告都位于已忽略的 .loop/,不会混进项目提交。
三个触发入口,最终都只能进入同一个队列
dispatch.py 支持三种触发来源:
| 触发类型 | 适合什么情况 | 它能提供什么 |
|---|---|---|
manual |
调试、人工确认后运行 | 明确的人为启动时机 |
schedule |
每隔一段时间检查积压任务 | 固定时间窗口,不保证一定有任务 |
event |
新任务文件或外部事件到达 | 指向一个已存在任务的结构化通知 |
它们不会获得三套不同权限。无论谁触发,Dispatcher 都要检查同一个 tasks/inbox/、同一个验证注册表和同一套预算。
先使用 Dry Run,不创建 worktree、不移动任务。手动触发预演:
python tools/dispatch.py --trigger manual --dry-run
定时触发预演:
python tools/dispatch.py --trigger schedule --dry-run
两者都应返回:
{
"decision": "planned",
"task_id": "issue-007",
"worktree": ".loop/worktrees/issue-007",
"side_effects": []
}
trigger 字段不同,但任务和计划工作区相同。Dry Run 的 side_effects 为空,独立检查:
git status --short
git worktree list
Git 状态仍为空,worktree 列表只有主工作区。
事件不能携带任意命令
打开 events/inbox/issue-007.json,内容只有:
{
"event_type": "task.created",
"task_id": "issue-007",
"occurred_at": "2026-09-24T00:00:00Z"
}
事件只指向队列里已经存在的稳定任务 ID。它不能提供 Shell 命令、验证入口、写入路径或 Agent Prompt。预演:
python tools/dispatch.py \
--trigger event \
--event events/inbox/issue-007.json \
--dry-run
Windows PowerShell 可以写成一行:
python tools/dispatch.py --trigger event --event events/inbox/issue-007.json --dry-run
应得到同一项 planned 结果。如果把 event_type 改成 run.shell,入口会以非零状态拒绝;它不会根据事件名字扩大能力。
真实事件系统还应持久化并去重 event_id。本地示例依靠任务 ID 和队列终态避免同一任务再次领取,没有实现通用 Webhook 去重。
单实例锁保护的是调度,不是整个世界
Dispatcher 启动后会尝试创建:
.loop/dispatch.lock/
同一文件系统里,只有一个进程能成功创建不存在的目录。取得锁的进程会写入 owner.json,记录 PID、主机名和取得时间;第二个进程看到目录已存在,就返回:
{
"decision": "stopped",
"reason": "already_running"
}
正常退出时,finally 会释放锁。Dry Run 之后检查,macOS 或 Linux:
test ! -d .loop/dispatch.lock && echo "lock released"
Windows PowerShell:
if (-not (Test-Path .loop/dispatch.lock)) { "lock released" }
应看到 lock released。
目录锁解决“同一个本地项目里不要重叠调度”的问题。它不是跨机器分布式锁,也不能保护 Agent 访问数据库、网络或其他目录。多个主机共同消费一个远端队列时,应使用队列服务或数据库提供的租约与原子领取,而不是共享一个本地目录。
先证明主工作区还没有标签搜索
运行新的验收:
node --test acceptance/tag-search.test.mjs
测试应失败。书目有 tags: ["systems", "computer science"],当前搜索文本只包含标题、作者和 ISBN。
记住这个失败。稍后隔离 worktree 中的同一测试会通过,而主工作区仍会失败,这正是候选隔离是否生效的独立证据。
手动调度一次隔离任务
现在执行真实调度:
python tools/dispatch.py --trigger manual
成功输出的关键字段如下:
{
"decision": "completed",
"reason": "isolated_candidate_verified",
"trigger": "manual",
"task_id": "issue-007",
"worktree": ".loop/worktrees/issue-007",
"patch": ".loop/candidates/issue-007/candidate.patch",
"report": ".loop/candidates/issue-007/report.json",
"worker_exit_code": 0
}
Dispatcher 实际做了五步:
取得调度锁
-> 从 inbox 选择 issue-007
-> 从当前 HEAD 创建 detached Git worktree
-> 在 worktree 内生成并验证候选
-> 把 patch 与报告写回主工作区的 .loop/candidates/
任务进入 tasks/done/,这里的 done 表示“候选已独立验证并等待审阅”,不是“已合并到主分支”,更不是“已发布”。
用两个目录证明隔离确实存在
先看 Git 注册的工作区:
git worktree list
应有两个条目:主工作区,以及 detached 状态的 .loop/worktrees/issue-007。
在主工作区重新运行标签验收:
node --test acceptance/tag-search.test.mjs
它仍应失败。再进入候选 worktree 运行同一条测试。
macOS 或 Linux:
cd .loop/worktrees/issue-007
node --test acceptance/tag-search.test.mjs
cd ../../..
Windows PowerShell:
Set-Location .loop/worktrees/issue-007
node --test acceptance/tag-search.test.mjs
Set-Location ../../..
候选 worktree 中应通过。两个目录来自同一个 Git HEAD,却有不同的未提交工作树:Agent 的标签搜索改动没有进入主目录。
查看候选而不应用它:
git apply --stat .loop/candidates/issue-007/candidate.patch
git apply --check .loop/candidates/issue-007/candidate.patch
python -m json.tool .loop/candidates/issue-007/report.json
--stat 应只列出 src/search.mjs;--check 没有输出且退出码为 0,表示 patch 可以应用到当前主工作区,但它不会真的修改文件;报告应显示 tag-search 的三条验证命令通过、边界错误为空。
再检查主工作区:
git diff -- src/search.mjs
git status --short
源文件没有 Diff。Git 状态只显示 issue-007 从 inbox 移到 done,.loop/ 中的候选不会出现。
审核后再清理 worktree
本教程默认不自动应用候选。你可以保留 worktree继续检查,或在确认 patch 和报告均已保存后删除隔离目录。
下面的命令会丢弃 worktree 中尚未提交的候选文件,只在你已经验证 .loop/candidates/issue-007/candidate.patch 非空且可读后执行:
git worktree remove --force .loop/worktrees/issue-007
git worktree prune
git worktree list
最后列表应只剩主工作区。--force 是必要的,因为候选 worktree 故意保留未提交 Diff;可恢复副本是主目录 .loop/candidates/issue-007/candidate.patch。
任务终态可以提交,但候选代码仍不合并:
git add tasks
git commit -m "chore: record isolated tag-search candidate"
git status --short
工作区应干净,标签验收在主目录仍失败。这清楚地区分了三件事:候选已验证、候选已审核、候选已合并。自动化程度越高,越不应该把它们压成一个模糊的“完成”。
怎样接入系统定时器
Dispatcher 每次启动最多处理一个任务,然后退出。系统定时器负责“过一会儿再来看”,而不是让 Python 写一个永久 while true。
macOS 或 Linux 可以使用现有的 cron、systemd timer 或平台调度器。以 cron 为例,先用 pwd 找到绝对路径,再把下面的占位符替换成你的实际路径:
*/15 * * * * cd /绝对路径/issue-garden-lab && /绝对路径/issue-garden-lab/.venv/bin/python tools/dispatch.py --trigger schedule >> .loop/scheduler.log 2>&1
它每 15 分钟检查一次。队列为空时 Dispatcher 返回 no_task 并结束,不会制造任务。cron 环境变量通常比交互终端少,所以要使用绝对 Python 路径和明确工作目录。
Windows 可以打开“任务计划程序”,选择“创建基本任务”,依次填写:
- 触发器:按你需要的间隔;
- 操作:启动程序;
- 程序或脚本:
issue-garden-lab\.venv\Scripts\python.exe的绝对路径; - 参数:
tools\dispatch.py --trigger schedule; - 起始于:
issue-garden-lab的绝对路径。
先把计划设为“仅在用户登录时运行”,手动点击“运行”并查看 .loop/scheduler.log 或任务历史。确认工作目录、Python 环境和权限都正确后,再决定是否允许无人值守。
定时频率不是越高越好。间隔应该大于常见任务耗时,锁只是最后防线;如果每次都因 already_running 跳过,说明调度频率或任务粒度需要调整。
常见阻塞
提示 worktree_already_exists
先运行:
git worktree list
如果该目录包含尚未审阅的候选,继续审阅,不要覆盖。如果 patch 已保存且决定放弃候选,再按上一节的 git worktree remove --force 清理。
锁一直存在
硬断电或强制结束进程可能来不及执行 finally。先查看:
python -m json.tool .loop/dispatch.lock/owner.json
确认记录的主机和 PID 已经没有运行中的 Dispatcher,再把整个锁目录改名保存现场,而不是直接假定它过期:
macOS 或 Linux:
mv .loop/dispatch.lock .loop/dispatch.lock.stale
Windows PowerShell:
Move-Item .loop/dispatch.lock .loop/dispatch.lock.stale
若进程仍在运行,应先调查它为什么耗时过长。强行清锁会重新引入重叠调度。
worktree 是不是安全沙箱
不是。Git worktree 隔离代码目录和未提交 Diff,但仍共享 Git 对象库,也通常以同一个操作系统用户运行,拥有相同网络和本机文件权限。高风险或不受信任 Agent 还需要容器、沙箱、独立用户、只读挂载和网络策略。
事件到达就能直接执行吗
事件只触发一次队列检查。示例会校验事件类型与任务 ID,并要求对应任务已经在 inbox。不要把 Webhook 正文直接拼成命令或 Prompt;先认证来源、限制大小、解析为受控字段,再创建任务。
本篇留下了什么
Issue Garden 现在有了统一触发入口:手动、定时和事件只决定“何时检查”,不会改变任务权限;原子目录锁阻止本地重叠调度;每项候选在 detached Git worktree 中生成和验证,主工作区只接收报告、patch 和队列终态。
下一篇,我们会在这个受控运行框架上补齐全局资源治理:除了每轮文件数和 Diff 行数,还要追踪任务总时长、Agent 运行次数、可选 Token/费用指标,以及真正需要人注意的通知,避免“安全地运行”变成“安全地烧预算”。
修订后的边界验收
candidate.patch 使用临时 Git 索引,以 HEAD 为基线收集已跟踪变化与未忽略的新文件,不修改真实索引。清理前用 git apply --stat 查看清单,并在干净副本执行 git apply --check;新增 src 文件必须出现在补丁中。
10 控制成本风险和人工注意力
预计阅读与练习时间:50~70 分钟
本篇目标:为循环加入三道彼此独立的边界——资源预算、能力风险门和人工注意力路由;用三个离线案例验证它会正常收尾、因超预算停止,也会在外部写入发生前请求人工处理。
上一篇解决了“什么时候运行、由谁运行、候选改动放在哪里”。但隔离 worktree 只保护代码工作区,它没有回答三个更现实的问题:
- 一个任务最多可以调用几次 Agent、运行多久、消耗多少 Token 或费用?
- Agent 想访问网络、读取凭据或者写入外部系统时,谁有权放行?
- 每次成功、重试和暂停都通知人,用户很快就会把通知全部关掉;究竟哪些状态值得立刻打扰?
这三个问题不能合并成一个“预算”数字。我们会把它们分别实现为:
资源预算:这项任务最多消耗多少计算资源
风险边界:这项任务最多可以动到哪里
注意力预算:什么情况值得马上叫人
本篇示例依然完全离线。所谓“通知”只是写入本地 outbox,不会发送邮件或消息;费用只读取 Agent 适配器报告的值,不会调用真实模型,也不会硬编码某家厂商随时可能变化的价格。
从第 7 篇的干净主工作区继续
第 7 篇结束后,issue-007 的候选 patch 已保存在:
.loop/candidates/issue-007/candidate.patch
我们没有把它合并进主分支,并且已经移除了临时 worktree。因此主工作区中的标签搜索验收仍然失败,这是预期状态。先检查:
git status --short
git worktree list
第一条命令应没有输出;第二条只应列出主工作区。然后从课程根目录复制本篇增量。
macOS 或 Linux:
cp -R assets/lesson-08-governance/. issue-garden-lab/
cd issue-garden-lab
source .venv/bin/activate
Windows PowerShell:
Copy-Item -Recurse -Force assets/lesson-08-governance/* issue-garden-lab
Set-Location issue-garden-lab
.\.venv\Scripts\Activate.ps1
这次新增的文件不多:
governance.yaml 三类边界的策略
issue_garden/governance.py 风险检查、预算账本和通知路由
tools/governance_demo.py 离线演示入口
governance_cases/completed.json 正常完成案例
governance_cases/budget-exhausted.json 超预算案例
governance_cases/risk-boundary.json 越过权限边界案例
先运行全部 Python 测试:
python -m unittest discover -s loop_tests -p "test_*.py" -v
预期二十二项通过。本篇新增四项,分别检查成功任务进入摘要、超预算只生成一条去重通知、风险门先于 Agent 运行,以及缺失费用不能冒充零费用。
把课程代码提交,避免后面的本地账本混入代码改动:
git add .
git commit -m "feat: add loop governance policies"
git status --short
最后一条命令应没有输出。.loop/ 已被忽略,稍后产生的账本和通知不会进入 Git。
先读懂三类边界
打开 governance.yaml。其中的默认策略是:
budgets:
max_agent_runs_per_task: 3
max_wall_seconds_per_task: 1800
max_reported_input_tokens_per_task: 20000
max_reported_output_tokens_per_task: 8000
max_reported_cost_usd_per_task: 1.00
warning_at_fraction: 0.80
require_token_visibility: false
require_cost_visibility: false
risk:
allowed_unattended_capabilities:
- workspace_read
- workspace_write
- local_test
human_approval_required:
- network_access
- secret_access
- external_write
- destructive_action
资源预算不是只有费用
本例同时记录五项指标:
| 指标 | 它限制什么 | 为什么不能只看费用 |
|---|---|---|
agent_runs |
Agent 调用次数 | 免费或包月调用也可能原地循环 |
wall_seconds |
任务累计墙钟时间 | 工具卡住时,Token 可能几乎不增长 |
input_tokens |
适配器报告的输入量 | 上下文不断膨胀是常见失控信号 |
output_tokens |
适配器报告的输出量 | 防止一次生成无限扩张 |
estimated_cost_usd |
适配器报告的估算费用 | 方便在按量计费时设置硬上限 |
真正接入 Agent 时,最好同时设置两层限制:在单次模型调用处设置最大输出等“单轮上限”,在循环账本中设置整项任务的“累计上限”。后者不能替代前者,因为事后记账只能阻止下一轮,无法收回刚刚已经消耗的资源。
课程里的金额只是用于测试边界的示例值,不是模型价格建议。不同 Provider、模型、缓存策略和时间点的价格都可能不同。应由 Agent 适配器把实际用量或按当时价格算出的估值写入账本,而不是让 Runner 猜。
风险边界限制的是能力,不是次数
本地读写和运行本地测试可以无人值守;网络访问、读取真实凭据、写外部系统和破坏性操作必须先找人。这条规则即使预算还有很多也不会改变:
“还能调用两次 Agent”
不等于
“可以替用户发布、删数据或使用生产密钥”
因此风险检查必须发生在 Agent 运行之前。否则系统先做了外部写入,再把任务标成 blocked,控制就已经失效。
注意力预算决定什么时候打扰人
文件下半部分定义:
attention:
immediate_reasons:
- needs_input
- permission_boundary
- budget_exhausted
- usage_visibility_required
- unsafe_resume
digest_decisions:
- completed
silent_reasons:
- no_task
- retry
- planned
对应三条路线:
| 路线 | 典型状态 | 处理方式 |
|---|---|---|
| 立即通知 | 缺少输入、权限越界、预算耗尽、必要用量不可见、无法安全恢复 | 现在需要人做决定 |
| 汇总通知 | 已完成并通过验证 | 放入日报或批次摘要 |
| 静默记录 | 没有任务、已安排重试、Dry Run | 写日志即可,不打断人 |
“成功就立刻通知”听起来积极,任务一多却会制造通知疲劳。Loop Engineering 的目标不是让系统显得忙,而是把人的注意力留给确实需要判断的地方。
边界应该在运行前、运行中和运行后各检查一次
只在任务结束后读取总费用还不够。一套实际的 Runner 至少有三个检查点:
运行前
-> 检查任务请求的能力
-> 检查剩余调用次数和剩余时间是否足够开始一轮
单轮运行中
-> 由 Agent/Provider 限制单次输出、超时和工具权限
运行后
-> 读取适配器报告的真实用量
-> 累加到账本
-> 决定完成、继续,还是禁止下一轮
本篇的 governance_demo.py 专门演示“运行前风险门”和“运行后累计裁决”。它不是真实 Agent 适配器,所以三个 JSON 案例直接提供一次运行的用量报告。
还有一个容易忽略的细节:指标缺失不等于零。若某个 CLI Agent 不报告费用,账本会把 estimated_cost_usd 记入 unavailable_metrics,而不是宣称这次调用花费为 0。你可以暂时允许费用不可见,也可以把 require_cost_visibility 改为 true,让不可观测本身成为阻塞原因。
案例一:正常完成只进入摘要
查看 governance_cases/completed.json:
{
"task_id": "governance-completed",
"requested_capabilities": [
"workspace_read",
"workspace_write",
"local_test"
],
"usage": {
"agent_runs": 1,
"wall_seconds": 38,
"input_tokens": 6200,
"output_tokens": 1300,
"estimated_cost_usd": null
},
"decision": "completed",
"reason": "verified"
}
运行:
python tools/governance_demo.py \
--case governance_cases/completed.json
Windows PowerShell 可以写成一行:
python tools/governance_demo.py --case governance_cases/completed.json
关键输出应为:
{
"decision": "completed",
"reason": "verified",
"attention_route": "digest",
"notification": null
}
这里有两项值得单独核对。第一,完成任务没有写入即时通知 outbox:
macOS 或 Linux:
find .loop/notifications/outbox -type f 2>/dev/null
Windows PowerShell:
Get-ChildItem .loop/notifications/outbox -File -ErrorAction SilentlyContinue
此时应没有输出,目录也可能尚不存在。第二,检查持久账本:
python -m json.tool .loop/budgets/governance-completed.json
你会看到 agent_runs 为 1、input_tokens 为 6200,同时:
"unavailable_metrics": [
"estimated_cost_usd"
]
账本中的累计金额字段初始值仍是 0.0,但不能据此得出“这次免费”的结论。unavailable_metrics 才是可观测性事实:这次案例没有提供费用。
案例二:超过资源预算,禁止下一轮
第二个案例报告了 21500 个输入 Token 和 1.15 美元估算费用,分别超过示例上限 20000 和 1.00:
python tools/governance_demo.py --case governance_cases/budget-exhausted.json
命令会以退出码 2 结束。这不是程序崩溃,而是 Runner 用非零退出码告诉外层调度器“任务没有完成”。关键输出为:
{
"decision": "stopped",
"reason": "budget_exhausted",
"usage": {
"exceeded": [
"input_tokens",
"estimated_cost_usd"
]
},
"attention_route": "immediate"
}
如果终端只显示 JSON,不直观展示退出码,可以紧接着查看。
macOS 或 Linux:
echo $?
Windows PowerShell:
$LASTEXITCODE
应为 2。再独立检查账本:
python -m json.tool .loop/budgets/governance-budget.json
ratios.input_tokens 应为 1.075,ratios.estimated_cost_usd 应为 1.15。比率超过 1 表示已越过硬上限;它们不会因为其他指标仍很低而相互抵消。
这次还应出现一条本地通知。macOS 或 Linux:
find .loop/notifications/outbox -type f -print
Windows PowerShell:
Get-ChildItem .loop/notifications/outbox -File
打开列出的 JSON,内容只有任务 ID、终态、原因和必要的越界字段,没有完整 Prompt、原始日志或凭据。实际接入飞书、Slack 或邮件时,也应让消息通道读取这种最小化 payload,而不是把整个运行现场原样外发。
测试用例还验证了相同的 task_id + reason 不会重复创建通知,而且已耗尽预算的任务不会再次入账。生产系统仍应为“用量事件”设置唯一 ID:只要任务尚未到达硬上限,同一份报告重复提交就仍可能重复累计。
案例三:权限越界必须先于运行被挡住
最后一个案例请求 external_write,代表向代码工作区之外的系统写入:
python tools/governance_demo.py --case governance_cases/risk-boundary.json
命令同样以退出码 2 结束,但原因不同:
{
"decision": "blocked",
"reason": "permission_boundary",
"risk": {
"allowed": false,
"requires_approval": [
"external_write"
]
},
"usage": {
"totals": {
"agent_runs": 0
}
},
"attention_route": "immediate"
}
agent_runs 仍为 0,是最重要的验证结果:风险门在执行前拦截了任务。因为没有运行,也就不会生成该任务的预算账本:
macOS 或 Linux:
test ! -f .loop/budgets/governance-risk.json \
&& echo "blocked before execution"
Windows PowerShell:
if (-not (Test-Path .loop/budgets/governance-risk.json)) { "blocked before execution" }
应看到 blocked before execution。outbox 中则多了一条 permission_boundary 通知,告诉人需要审批哪一项能力。
审批不应该简单地把原任务改成“什么都能做”再自动重放。更稳妥的做法是让人确认目标、目标系统和可接受副作用,然后创建一个带明确授权范围的新任务,或者把外部写入拆成可审阅的人工步骤。旧任务及其拒绝记录应保留,便于审计。
把这三道边界接回真正的循环
本篇用独立工具讲清了策略,真正整合到第 7 篇 Dispatcher 时,可以按下面的顺序接入:
Dispatcher 领取任务
-> 根据任务声明评估 requested_capabilities
-> 风险不允许:blocked + immediate
-> 检查账本中的剩余硬预算
-> 创建隔离 worktree
-> 运行一轮 Agent,并由适配器返回 usage
-> 把 usage 作为带唯一 ID 的事件入账
-> 执行独立验证
-> 完成:digest
-> 可重试且仍有预算:silent retry
-> 预算耗尽或需要输入:停止并 immediate
有两条顺序不能颠倒:
- 先检查能力,再启动 Agent。风险边界不能靠事后报告补救。
- 先保存用量,再决定是否重试。否则进程恰好在两者之间中断,恢复后可能忘记已经消耗的预算。
如果 Agent CLI 不能报告 Token 或费用,先使用可观测的调用次数与墙钟时间做硬限制,并在报告中明确标出未知项。不要通过估算字符数制造虚假的精确费用。等适配器可以稳定返回用量,再根据业务需要打开 require_token_visibility 或 require_cost_visibility。
预算不是越小越安全,也不是越大越好
预算频繁耗尽时,先判断任务是否过大,而不是立刻调高上限。一个任务如果需要十几轮才能说清目标,通常意味着它缺少可验证的中间结果,或者应该拆成多个独立任务。
可以按下面的顺序调整:
- 缩小任务范围,让一项任务只产生一个可验证结果。
- 减少无关上下文,避免每轮重复加载大量历史。
- 让失败签名触发重新规划或升级,避免同一种失败消耗剩余轮次。
- 根据真实账本观察正常任务分布,再调整告警线和硬上限。
warning_at_fraction: 0.80 表示到达上限的 80% 时进入警告区,但课程示例不会仅因警告停止。实际系统可以在警告出现后禁止开启一项昂贵的新动作,或者要求 Agent 先提交当前成果和剩余计划。
常见阻塞与处理办法
1. 费用一直显示为不可用
先确认所用 Agent 或 Provider 是否真的返回用量。若不返回,保持 require_cost_visibility: false,同时用调用次数与总时长做硬边界;如果组织规定费用不可见就不得无人值守,则把它改为 true,让任务明确进入阻塞,而不是把未知记成零。
2. 每次循环都收到很多消息
检查是否把 retry、planned 或 no_task 放进了立即通知。它们应默认静默,只进入结构化日志。成功任务适合批量摘要;只有需要人作出决定的状态才立即通知。还应在消息出口增加去重键、冷却时间和批次合并。
3. 风险任务明明“很简单”却被拒绝
策略按能力和副作用判断,不按操作描述听起来是否简单判断。“把结果顺手发到线上表格”依然是外部写入。若确实需要自动执行,应由人明确授权目标系统、凭据范围、允许动作和有效期,而不是临时放开全部外部能力。
4. 账本恢复后重复累计
本地示例只演示累计和通知去重。生产适配器必须为每次用量报告生成稳定事件 ID,并在账本中拒绝重复 ID;写入仍应使用原子替换或事务。否则进程在“已记账、未写 checkpoint”之间中断,恢复后会重复计费。
5. 达到时间预算时进程仍在运行
总账本只能阻止下一轮。单轮执行还需要子进程超时、可取消的工具调用和退出后的清理逻辑。强制终止前先保存当前状态;对于外部副作用不明确的任务,恢复时进入 unsafe_resume,不要直接重放。
本篇完成检查
完成下面几项,就说明三类边界已经真正生效:
- 二十二项 Python 测试通过;
- 正常案例返回
completed,进入digest,没有即时通知; - 正常案例费用为“不可用”,而不是伪装成零费用;
- 超预算案例返回
budget_exhausted,账本准确列出两项越界指标; - 风险案例在
agent_runs = 0时停止,没有先执行再阻塞; - outbox 只包含需要人处理的预算和权限事件;
- 所有实验仍然只写本地
.loop/,没有真实消息、网络请求和外部写入。
现在,循环已经具备契约、状态、独立验证、失败升级、触发调度、隔离和三类治理边界。下一篇会把这些部件串成一次综合实战,用三个任务分别证明:它能够完成可验证的小缺陷、面对信息不足会求助、遇到越权动作会拒绝,而且每条路径都会明确结束。
11 综合实战
预计阅读与练习时间:70~90 分钟
本篇目标:把前面八篇的部件串成一次完整实验,用三个任务证明循环能够完成、求助和拒绝,并且三条路径都有持久状态、资源账本、通知路线和明确终点。
到这里,我们已经分别实现了契约、状态、独立验证、失败升级、触发、锁、隔离、预算和风险门。最后一篇不再增加一个孤立概念,而是回答更重要的问题:这些部件按什么顺序组合,才能让循环在现实任务中可靠地结束?
综合实验包含三个任务:
| 任务 | 预期终态 | 为什么这样结束 |
|---|---|---|
| 按丛书名称搜索 | completed |
隔离候选通过独立验证 |
| 按评分筛选 | needs_input |
两轮得到相同失败,缺少产品约定 |
| 发布到外部系统 | blocked |
在 Agent 运行前触发权限边界 |
三个任务都由同一个入口处理。它不会因为任务听起来简单就扩大权限,也不会把“没有报错”当成完成。
确认第 8 篇留下的起点
当前工作目录应为 issue-garden-lab/,虚拟环境已经激活。第 8 篇的治理案例可能在 .loop/ 中留下预算账本和本地通知,它们都被 Git 忽略,不影响本篇。
先检查主工作区:
git status --short
git worktree list
第一条命令应没有输出,第二条只应列出主工作区。第 7 篇的标签搜索候选仍保存在 .loop/candidates/issue-007/,但临时 worktree 已删除,候选也没有合并。这是本篇故意保留的安全边界。
从课程根目录复制综合实战增量。
macOS 或 Linux:
cp -R assets/lesson-09-capstone/. issue-garden-lab/
cd issue-garden-lab
source .venv/bin/activate
Windows PowerShell:
Copy-Item -Recurse -Force assets/lesson-09-capstone/* issue-garden-lab
Set-Location issue-garden-lab
.\.venv\Scripts\Activate.ps1
新增的核心文件如下:
tools/run_capstone.py 单次调度入口
tools/run_capstone_worker.py 隔离 worktree 内的 Worker
tools/check_capstone.py 三项终态的独立汇总检查
issue_garden/capstone.py 风险门、锁、worktree、账本和终态
issue_garden/capstone_worker.py 多轮生成、验证和失败升级
issue_garden/capstone_generator.py 确定性模拟 Agent
tasks/inbox/capstone-001..003.json 三项综合任务
运行累计测试:
python -m unittest discover -s loop_tests -p "test_*.py" -v
修订版预期三十三项通过(原二十五项加八项回归)。原有综合测试检查综合任务的固定顺序、needs_input 的立即通知路线,以及每项任务都显式声明所需能力。
提交本篇代码和任务。worktree 始终从 Git HEAD 创建;不先提交的话,隔离目录不会包含刚复制的综合实战文件。
git add .
git commit -m "feat: add loop engineering capstone"
git status --short
最后一条命令应没有输出。
先看一次调度到底经过哪些门
run_capstone.py 每次只领取编号最小的一项 capstone-*.json,然后退出。外层定时器可以稍后再次调用它,但程序本身没有永久循环。
一次调用的实际顺序是:
取得单实例锁
-> 从 inbox 选择一个任务
-> 读取 requested_capabilities
-> 风险检查
├─ 不允许:不启动 Agent,直接 blocked
└─ 允许:检查已有资源账本
-> 从当前 HEAD 创建 detached worktree
-> 在 worktree 内运行模拟 Agent
-> 独立 Verifier 裁决候选
-> 失败时比较失败签名,决定重试或求助
-> 把每轮用量写入主工作区账本
-> 保存 patch、attempt、state、events 和 result
-> 移动任务到终态目录
-> 按注意力策略路由通知
-> 释放锁并结束
这里没有一个“万能 Agent”负责自我监督。风险门、生成器、验证器和终态决策是不同部件;Agent 的文字总结只是证据之一,不能替代测试、Diff 和策略。
本篇只处理文件名以 capstone- 开头的任务。即使你此前跳过某篇、tasks/inbox/ 里还留有旧练习,也不会被综合入口误领。
先证明第一个功能目前不存在
任务一要求书目可以按可选的 series 字段搜索。先在主工作区运行验收:
node --test acceptance/series-search.test.mjs
测试应失败:查询 chronicles 找不到 series: "Dune Chronicles"。这条失败建立了实验基线;如果它一开始就通过,说明你的主工作区已经有等价改动,不适合继续使用本例的确定性生成器。
再查看任务声明:
python -m json.tool tasks/inbox/capstone-001.json
它只请求:
[
"workspace_read",
"workspace_write",
"local_test"
]
这些能力都在无人值守白名单中,所以风险门允许进入隔离执行。
任务一:生成并验证一个可审阅候选
运行一次综合入口:
python tools/run_capstone.py
命令应以退出码 0 结束。输出较长,因为它保留了生成、验证和用量证据;先关注这些字段:
{
"task_id": "capstone-001",
"decision": "completed",
"reason": "independent_verification_passed",
"attention_route": "digest",
"worktree": ".loop/worktrees/capstone-001",
"patch": ".loop/capstone/capstone-001/candidate.patch",
"task_destination": "tasks/done/capstone-001.json"
}
“完成”仍然表示候选通过验证,不表示已经合并。先在候选 worktree 中重跑验收:
node --test .loop/worktrees/capstone-001/acceptance/series-search.test.mjs
应有一项测试通过。再回到主工作区运行同一验收:
node --test acceptance/series-search.test.mjs
它仍应失败。这个对照比 Agent 的“已完成”更有说服力:功能只存在于隔离候选,主工作区没有被偷偷修改。
检查 patch 是否只触碰预期文件:
git apply --stat .loop/capstone/capstone-001/candidate.patch
git apply --check .loop/capstone/capstone-001/candidate.patch
第一条应只列出 src/search.mjs;第二条没有输出且退出码为 0。--check 只验证能否应用,不会真的应用 patch。
最后检查资源和通知:
python -m json.tool .loop/budgets/capstone-001.json
python -m json.tool .loop/capstone/capstone-001/result.json
账本中的 agent_runs 应为 1;结果中的 attention_route 为 digest、notification 为 null。正常完成被留到摘要,不会立即打断人。
任务二:重复失败后停止猜测
第二项任务想按评分筛选,却没有说明:
rating是单个数字、用户评分数组,还是聚合后的平均值;- 使用 5 分制、10 分制,还是百分制;
- 查询语法是
rating:5、>=4,还是界面传入结构化条件; - 没有评分的书应该排除、保留,还是单独归类。
这些不是代码风格问题,而是产品语义。模拟 Agent 第一轮不会猜字段,Verifier 也不会接受“没有改动”。运行:
python tools/run_capstone.py
这次退出码应为 2,关键结果是:
{
"task_id": "capstone-002",
"decision": "needs_input",
"reason": "same_failure_limit_reached",
"attention_route": "immediate",
"task_destination": "tasks/needs-input/capstone-002.json"
}
不要把非零退出码误判成 Python 崩溃。它表示调度器得到了一个正常但未完成的终态,方便 cron、任务计划程序或上层队列识别。
检查两轮尝试:
python -m json.tool .loop/capstone/capstone-002/attempts/round-001.json
python -m json.tool .loop/capstone/capstone-002/attempts/round-002.json
两份记录的 failure_signature 应相同,第二轮的 consecutive_same_failure 为 2。它说明第二轮没有带来新证据;继续启动第三轮只会消耗预算,因此系统请求人补充规则。
再检查代码候选:
macOS 或 Linux:
test ! -s .loop/capstone/capstone-002/candidate.patch \
&& echo "no guessed patch"
Windows PowerShell:
if ((Get-Item .loop/capstone/capstone-002/candidate.patch).Length -eq 0) { "no guessed patch" }
应看到 no guessed patch。系统不是“失败了但偷偷留下一半实现”,而是在缺少关键输入时保持代码不变。
账本仍然记录了已经发生的两轮调用:
python -m json.tool .loop/budgets/capstone-002.json
agent_runs 应为 2。needs_input 不是免费动作,也不是可以忽略的日志;它消耗过资源,现在需要人的判断,所以进入立即通知路线。通知仍只写本地 outbox,没有发送到真实消息系统。
如果你要让任务继续,不要直接篡改旧任务后重放。先补齐字段约定和验收示例,再创建新的任务 ID。旧结果保留为“当时为什么停下”的审计证据。
任务三:越权任务在 Agent 启动前结束
第三项任务要求把书目发布到外部线上系统。它声明了:
[
"workspace_read",
"network_access",
"secret_access",
"external_write"
]
课程没有提供目标系统、凭据范围、允许写入的对象或回滚方式。运行:
python tools/run_capstone.py
命令以退出码 2 结束,输出应包含:
{
"task_id": "capstone-003",
"decision": "blocked",
"reason": "permission_boundary",
"usage": {
"totals": {
"agent_runs": 0
}
},
"attention_route": "immediate",
"worktree": null,
"patch": null
}
agent_runs = 0、worktree = null 和 patch = null 共同证明:系统先检查权限,没有启动 Agent,也没有尝试访问网络后再道歉。
独立检查没有为它创建 worktree:
macOS 或 Linux:
test ! -d .loop/worktrees/capstone-003 \
&& echo "blocked before execution"
Windows PowerShell:
if (-not (Test-Path .loop/worktrees/capstone-003)) { "blocked before execution" }
应看到 blocked before execution。任务文件位于 tasks/blocked/capstone-003.json,结果报告则保存在 .loop/capstone/capstone-003/result.json。
如果未来确实要发布,应由人建立新的授权任务,至少写明目标系统、作用域、凭据来源、幂等键、预演方式和回滚方案。提高 Token 或轮次预算不能解决权限缺失。
证明队列会结束,而不是空转
再运行一次:
python tools/run_capstone.py
应得到:
{
"decision": "stopped",
"reason": "no_task",
"attention_route": "silent"
}
退出码仍为 2,因为本次没有完成新任务;但 no_task 是健康的停止条件,不是告警。定时器下次可以再检查,当前调用则立即结束,不会在空队列上无限消耗。
现在运行课程提供的汇总检查:
python tools/check_capstone.py
预期 verified 为 true,并得到三条终态:
{
"capstone-001": {
"decision": "completed",
"agent_runs": 1
},
"capstone-002": {
"decision": "needs_input",
"agent_runs": 2
},
"capstone-003": {
"decision": "blocked",
"agent_runs": 0
}
}
这个检查还会确认四件事:成功任务 patch 非空、求助任务 patch 为空、越权任务没有 worktree、综合任务 inbox 已清空。它读取的是持久产物,不依赖刚才终端里是否出现了“成功”字样。
顺着一项任务读完整证据链
以 capstone-002 为例,相关文件分成四类:
tasks/needs-input/capstone-002.json
原始任务事实与最终队列位置
.loop/capstone/capstone-002/facts.json
调度时保存的任务快照
.loop/capstone/capstone-002/attempts/round-001.json
.loop/capstone/capstone-002/attempts/round-002.json
每轮生成、验证、失败签名和用量
.loop/capstone/capstone-002/state.json
.loop/capstone/capstone-002/events.jsonl
.loop/capstone/capstone-002/result.json
当前状态、事件时间线和最终裁决
可以逐项打开:
python -m json.tool .loop/capstone/capstone-002/facts.json
python -m json.tool .loop/capstone/capstone-002/state.json
python -m json.tool .loop/capstone/capstone-002/result.json
events.jsonl 是一行一条 JSON 的追加日志,可直接查看:
python -c "from pathlib import Path; print(Path('.loop/capstone/capstone-002/events.jsonl').read_text())"
这条证据链让恢复和审计不依赖聊天上下文。真正的长期循环往往运行跨越数小时甚至数天;如果“为什么停下”只存在某次模型会话里,下一次运行就只能猜。
清理 worktree,但保留可审阅产物
任务一和任务二都通过了风险门,因此各自创建了 worktree。任务二没有代码 Diff,但目录仍保留运行现场。先确认:
git worktree list
应看到主工作区、capstone-001 和 capstone-002 三个条目。确认结果和 patch 已写回主工作区后,删除两个临时 worktree:
git worktree remove --force .loop/worktrees/capstone-001
git worktree remove --force .loop/worktrees/capstone-002
git worktree prune
git worktree list
--force 会丢弃 worktree 中未提交的候选,所以必须先确认 .loop/capstone/capstone-001/candidate.patch 存在且 git apply --check 通过。清理后列表应只剩主工作区,而保存在 .loop/capstone/ 的 patch、尝试和结果仍然存在。
把任务终态提交,候选代码继续保持未合并:
git add tasks
git commit -m "chore: record capstone terminal states"
git status --short
工作区应为空。主目录中的 series-search 验收仍然失败,这再次区分了“候选通过验证”和“代码已合并”。
接入真实 Agent 的边界检查
课程使用确定性模拟器,是为了让你每次都能复现三条终态。接入 Codex、Claude Code 或其他 CLI Agent 时,先替换:
issue_garden/capstone_generator.py
新的适配器至少要返回:
- 本轮是否产生候选;
- 简短、可持久化的结果摘要;
- Agent 或 Provider 能提供的 Token、耗时和费用数据;
- 可区分的工具错误,而不是只返回一段自然语言。
不要让适配器顺便接管验证、预算和通知。外层控制流程仍应保留:
受限任务输入
+ 明确能力声明
+ 隔离工作区
+ 独立验证
+ 持久状态
+ 资源账本
+ 失败升级
+ 明确终态
调用 CLI 时使用参数数组和明确工作目录,不要把任务文本拼进 Shell 字符串;凭据只在经过授权的任务中以最小作用域提供;真实外部写入先做 Dry Run,并为操作设计幂等键和回滚路径。
常见阻塞
1. 提示找不到综合任务文件
确认已经提交本篇增量:
git log -1 --oneline
git ls-files "tasks/inbox/capstone-*.json"
worktree 从 HEAD 创建,只有复制但未提交的文件不会进入隔离目录。
2. 第一个任务被生成器拒绝
若报告提示“没有找到课程当前的搜索文本”,先看:
git status --short
python -c "from pathlib import Path; print(Path('src/search.mjs').read_text())"
确定性模拟器只接受前面课程留下的基线,避免覆盖你的自定义改动。若你已经修改过搜索实现,请在新的实验副本中完成综合练习,或者自己调整适配器并重新审查验证边界。
3. 提示 worktree_already_exists
运行:
git worktree list
先检查该目录是否保存了尚未审阅的候选。只有确认 patch 和结果已回传,才使用 git worktree remove --force 清理;不要直接删除目录,否则 Git 仍可能保留 worktree 注册信息。
4. 第二项任务为什么一定失败
acceptance/rating-filter.test.mjs 是一个需求就绪门:在字段与交互约定未确认前,它明确失败。练习重点不是实现评分功能,而是证明循环不会用多轮猜测替代产品决策。补齐需求时应更新任务和受保护验收,并用新任务 ID 重新开始。
5. 汇总检查显示 verified: false
先读具体子项。若队列位置不对,检查对应 result.json;若成功 patch 为空,检查 Worker 输出和候选 worktree;若 inbox 未清空,确认三次调用是否都执行。不要为了让汇总变绿而手工移动任务,先找到控制流程在哪一步停止。
你现在真正掌握了什么
Loop Engineering 不是让 Agent “永远工作”,而是让一次又一次运行都受到工程约束。一个值得长期运行的循环,至少应该能回答:
- 它为什么现在启动,谁保证不会重叠运行?
- 它从哪里读取稳定任务事实,状态保存在哪里?
- Agent 每轮能修改什么,候选与主工作区怎样隔离?
- 谁独立判断成功,失败有没有稳定证据?
- 下一轮为什么值得运行;没有新证据时何时停止?
- 调用次数、时间、Token 和费用上限是什么?
- 哪些能力可以无人值守,哪些必须找人审批?
- 完成、求助、拒绝和空队列是否都有明确终态?
- 哪些状态立即通知,哪些进入摘要,哪些只记日志?
- 中断后能否从持久证据恢复,而不是依赖一段聊天记录?
这套本地实现不是生产级调度平台,但它已经把关键判断变成了可运行、可观察、可验证的控制结构。今后无论接入哪一种 Agent,先保住这些边界,再提高自动化程度。能够可靠停止的循环,才有资格长期运行。
修订后的预算、超时与中断规则
综合 worker 每轮启动前评估剩余预算并预留本课 mock 用量,启动前写 worker-state.json,每轮结束立即写 attempts。外层不再重复累计。把 max_agent_runs_per_task 设为 1,模糊需求任务最多运行一轮,第二轮前以 budget_exhausted 停止。
生成器在独立子进程运行;生成与验证共享单轮 deadline。外层还有整次 worker 的硬时限,超时按失败保存。POSIX 会终止进程组;Windows 进程树行为须在目标系统复验。
突然中断时不要删除已有 worktree 重跑。先检查 .loop/capstone/<id>/worker-state.json、attempts/、预算账本和候选差异:generating/verifying 阶段可能有副作用,只能人工核查;当前综合示例保守拒绝自动续跑。核查后把已保存证据、用量和后续决定写入恢复记录,以明确的新任务继续,不能把预算清零伪装成未运行。第 04/06 篇仍用于练习无副作用检查点的自动恢复。
本例 token/费用是 mock,不是供应商实际消费。接入真实 CLI 还必须实现认证、实际用量回传、失败输出协议、进程树取消以及单轮最大 token/费用限制;仅替换生成函数不足以用于无人值守运行。
12 知名开源智能体-Crush是如何做运行循环工程的
本章是迁移练习,不把 Crush 内部的一次 turn 等同于本课程的外部任务调度。预计用 20~30 分钟追踪三条调用链。
先固定所读源码
git clone https://github.com/charmbracelet/crush.git
cd crush
git rev-parse HEAD > ../crush-reading-commit.txt
git status --short
把提交号保存到阅读笔记。本文不声称对未来 main 分支路径永久有效;阅读时从该提交搜索符号,用本地跳转确认调用关系,引用源码时使用含该提交号的永久链接。
配置如何进入一次运行
从配置加载入口追到模型、工具权限和 LSP 的使用处。记录“文件或配置脚本 → 解析 → 校验 → 使用”的实际函数。lsp add、permissions allow/deny 若出现在该版本的配置脚本示例中,就属于配置语言,不是可直接在 shell 执行的通用命令;启动参数只能按本机 crush --help 验证。不要把裸 --yolo 当成一条命令,也不在练习中关闭权限检查。
回答:一个权限条目在哪里被检查?拒绝后调用者收到什么?权限控制决定允许执行什么,不是任务结果验收,也不等于操作系统沙箱。AGENTS.md 是否拆分,应按信息重复、职责和上下文成本判断,不按“一屏”长度。
停止与重复检测
搜索 toolResultOutputString 和循环检测调用者,追踪输入、签名、阈值和返回路径。原始工具文本可能带时间戳或临时路径;不能因为进入签名计算就称为“稳定文本”。用两次相同错误但不同时间戳的输出,判断该版本是否会识别为重复。
内部工具循环发生在一次 Agent turn 内,属于运行时控制;本课程多次运行之间的领取、重试、恢复是另一层。plan/coder 的角色分工也不自动构成独立验证器,必须查看谁制定验收、谁执行以及能否被候选修改。
关联标识与持久化
搜索 WithRunID、RunIDFromContext,追踪 RunID 如何进入事件与日志。它提供关联标识,单靠传递 ID 不提供去重;Mutex 串行化也不保证相同请求只执行一次。真正去重需要检查键、原子领取或持久记录。
再找到一次运行状态写入、加载和恢复的入口。写出一个中断点:此时已保存什么、可能重复哪些副作用、哪些状态必须由人确认。不要从“保存聊天历史”直接推出可恢复业务事务。
独立验收
交付一页阅读笔记:提交号、三条调用链的源码位置、一个重复检测反例、一个中断恢复边界。每条结论至少指向一个调用者与被调用者;没有找到的机制写“未在本次路径确认”,不要用概念相似代替实现证据。
参考:Crush 官方仓库。
13 进阶学习
企业级Agent工程架构实战营
让Agent连续工作之后 怎样让任务可恢复也能及时停止
让 Agent 连续运行,最容易观察到的是它一直在产生动作。更需要检查的却是:这些动作有没有推进目标,失败后是否仍在重复同一种尝试,预算耗尽时能否停下来。
Loop Engineering 知识库通过 Issue Garden 维护循环,带你建立执行、独立验证、状态保存与停止判断的流程。接下来,可以把这个本地闭环放到更复杂的任务里,检查它能否被恢复、接管和追踪。
先证明循环知道什么时候结束
选择一个范围明确的维护任务,为它写好验收条件。每一轮结束后,沿着下面几项决定下一步。

比如,Agent 修改代码后测试仍然失败,需要先看失败是否与修改有关、是否出现新信息。重复同一种改法却没有改善时,应调整策略或请求帮助,而不是继续消耗调用次数。
给任务设定轮次、时间和成本上限;遇到缺少输入或权限不足时,保存原因和当前状态。一次没有完成的运行,也应让接手的人看懂已经尝试了什么、还需要什么。
你可以主动中断一次本地任务,再检查是否能从记录中恢复。这个练习比只观察一次成功运行,更容易暴露状态保存和恢复逻辑的问题。
从本地循环 到可接管的长任务
如果任务变成定期经营分析,过程中要查数据、运行计算、等待审批,单机上的循环就需要补上更完整的运行管理。下面四项是检查起点。

例如,分析报告已生成,但创建工单时服务中断。恢复后应根据已保存的步骤和动作结果继续,先确认工单是否已经创建,再决定重试,避免重复提交。
多个人同时运行任务时,还需要区分任务归属、数据权限与预算。等待人工审批的任务要能暂停,批准或拒绝后再按明确的状态转换继续。
这时,停止条件、检查点和验证结果需要进入统一的运行模型。否则,每个业务各写一套循环,很难共同处理调度、故障接管和问题排查。
继续学习运行时与恢复架构
《企业级 Agent 工程架构实战营》围绕这些问题展开架构进阶实践,适合已经能实现基础 Agent、希望进一步建设复杂系统的工程师。
课程在自主分析 Loop 中讨论验证器、停止条件与自主性预算,并用统一的运行、任务、步骤、尝试和事件模型管理执行。随后练习检查点、持久化执行、人工中断与恢复,以及重复执行和副作用控制。
贯穿的零售经营分析与决策 DataAgent 项目,会将自主分析与确定性工作流结合:分析可以迭代补充证据,预算调整等动作则经过审批和受控执行。课程还安排分布式运行与故障注入,检查任务能否恢复和接管。
这些内容能帮助你把知识库中的本地 Runner 设计经验,扩展到调度、运行治理和评测体系,并用执行轨迹定位具体失败步骤。
带着一次失败运行 继续深入
整理一个没有顺利结束的任务:当时停在哪一步,已经产生什么结果,恢复需要什么信息,是否可能重复执行。它会成为很具体的架构练习材料。
想了解《企业级 Agent 工程架构实战营》的内容与学习要求,可以通过下方入口联系学习顾问,备注 「Loop 运行架构」,说明当前系统规模和遇到的长任务问题。
