Skip to content

Recovery Loop

本课交付结果

你将交付 recover(operation, options):失败后仅对 retryable 类别再次尝试;遇到不可重试、连续重复指纹或最大次数时明确停止,并返回冻结的有序失败历史。

岗位问题

恢复不是无条件循环。相同测试连续失败说明模型没有产生新信息;权限拒绝再次尝试不会改变事实;没有历史则模型无法比较两轮策略。生产 Agent 的恢复必须有终止证明。

前置检查

前置知识快照

先验证失败分类:

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 19

操作按 attempt 从 1 开始,返回成功值或已分类失败。循环不自行猜测异常类别,保持分类与控制分层。

operation(attempt) 已经把模型调用、工具执行、测试和失败分类组合成一次尝试;Recovery Loop只消费 {ok:true,value}{ok:false,failure}。它不解析异常文本,不自动安装依赖,也不修改权限。控制层越克制,终止条件越容易证明。

最大次数是调用operation的硬上限,不是失败后的额外重试数。maxAttempts:3 最多执行attempt一、二、三,共三次。配置必须为正整数,避免零、负数、小数和Infinity产生含糊循环。

原理拆解

每轮记录失败副本,然后按顺序检查:不可重试立即停止;更新连续指纹计数,达到阈值停止;否则进入下一轮。循环耗尽返回 max_attempts。任何返回路径都携带不可变历史。

mermaid
flowchart TD
  A[attempt = 1] --> B[调用 operation]
  B --> C{成功?}
  C -- 是 --> D[completed + frozen history]
  C -- 否 --> E[复制失败入 history]
  E --> F{retryable?}
  F -- 否 --> G[non_retryable]
  F -- 是 --> H[更新连续 fingerprint 次数]
  H --> I{达到重复阈值?}
  I -- 是 --> J[repeated_failure]
  I -- 否 --> K{还有 attempt?}
  K -- 是 --> B
  K -- 否 --> L[max_attempts]

失败必须先写历史再判断停止,否则最后一次证据丢失。permission第一次不可重试,history仍包含它;第二次相同test触发repeated,也必须保留两条。停止原因解释控制决策,history解释事实序列,两者缺一不可。

重复计数只看连续相同fingerprint。序列A、A达到阈值二停止;A、B、A中第三个A重新计数一,因为中间策略产生了不同失败,说明系统状态或路径有变化。若需求要统计全局反复,则是另一策略,不能混用一个变量。

默认两次相同失败停止,体现“相同策略没有新信息”。一次失败仍允许生成新补丁,第二次本质相同说明修复无效。阈值可配置,但必须正整数;设一表示任何retryable失败也立即以repeated停止,语义合法但通常不实用。

不可重试检查在重复与最大次数之前。permission第一次就返回non_retryable,而不是因为threshold一报告repeated;停止原因应指向最根本策略。循环耗尽才返回max_attempts,不能多调用一次来“确认”。

history复制每个failure并冻结数组与条目,防止operation复用对象、UI添加字段或Provider修改message后改变已发生事实。返回成功也保留之前失败历史,用户能看到系统如何恢复;没有失败时是冻结空数组。

本课不实现退避,因为test/typecheck恢复需要新策略而非等待;tool/timeout可能需要时间退避。进阶版根据kind选择delay,并让AbortSignal中断等待。所有等待计入总wall-clock预算,不能只限制attempt数量。

代码实验

失败实现

无界循环只要失败就继续,既不看类别也不记录证据:

ts
while (true) {
  try { return await operation(); }
  catch { /* 再试一次 */ }
}

正确实现把所有返回路径汇聚到冻结finish:

ts
const history: RecoveryFailure[] = [];
let previous: string | undefined;
let repeated = 0;

for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
  const result = await operation(attempt);
  if (result.ok) return finish({ status: "completed", value: result.value });
  history.push({ ...result.failure });
  if (!result.failure.retryable) {
    return finish({ status: "failed", reason: "non_retryable" });
  }
  repeated = previous === result.failure.fingerprint ? repeated + 1 : 1;
  previous = result.failure.fingerprint;
  if (repeated >= threshold) {
    return finish({ status: "failed", reason: "repeated_failure" });
  }
}
return finish({ status: "failed", reason: "max_attempts" });

finish映射复制并冻结每个条目,再冻结数组。TypeScript接口写成可变数组是Lab简化,生产类型应使用 readonly Readonly<RecoveryFailure>[],让不可变承诺在编译期可见。

打开本课 Lab

bash
pnpm --dir bootcamps/coding-agent/labs/20-recovery-loop/starter test
pnpm --dir bootcamps/coding-agent/labs/20-recovery-loop/solution test

Starter 应声明 实现重复失败停止;Solution 应通过 5 项测试。

重复停止测试同时断言调用次数:

ts
it("连续相同指纹两次后停止", async () => {
  const op = vi.fn().mockResolvedValue({
    ok: false,
    failure: failure("same"),
  });
  const result = await recover(op, {
    maxAttempts: 5,
    maxRepeatedFingerprint: 2,
  });
  expect(result.reason).toBe("repeated_failure");
  expect(op).toHaveBeenCalledTimes(2);
  expect(result.history).toHaveLength(2);
});

关键实现讲解

重复计数针对连续相同指纹;出现新失败后重置为 1。默认两次相同失败即停止,阈值和最大次数都必须是正整数。历史复制并冻结,避免返回后被 UI 或 Provider 改写。

operation抛异常时的合同要明确。Lab要求它总返回AttemptResult,若意外throw会让recover reject;生产组合层可catch后用第19课classifyFailure转换,再进入同一历史。不要在循环里写第二套文本分类,错误归一仍由唯一组件负责。

成功值不需要冻结,所有权交给调用方;历史必须冻结因为它是审计事实。若value也包含工作区状态,应由相应领域定义不可变性。不要为了统一深冻结任意对象,getter和循环引用会引发风险。

运行与验证

真实运行输出

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 20
pnpm --filter @coding-agent/agent-core test

失败产物检查:观察 reasonnon_retryablerepeated_failuremax_attempts;history 中的指纹顺序必须与 attempt 一致,且 Object.isFrozen(history) 为 true。

真实Solution五项应全部通过:

text
solution: 5 passed, 0 failed
覆盖: 一次失败后成功 / permission不重试 / 重复指纹停止 / 变化失败到上限 / 冻结有序历史

再测试非法配置、A-B-A计数重置、成功空历史和operation意外throw。对每条失败路径同时断言reason、operation调用次数、history长度与冻结状态,避免多执行一次副作用。

常见失败与排查

故障案例 1

用户拒绝后仍连续弹审批。

症状:permission失败被调用五次,直到maxAttempts才停止。

根因:循环忽略retryable,或分类器错误把所有失败设true。

定位:传入不可重试夹具,断言operation恰好一次、reason为non_retryable。

修复:记录历史后立即检查retryable;新授权只能作为外部新操作启动。

故障案例 2

相同失败因临时路径不同逃过停止。

症状:每轮测试本质相同,但消息行号变化导致一直尝试到最大次数。

根因:循环比较message全文而不是分类器提供的稳定fingerprint。

定位:构造消息不同但fingerprint相同的两条failure,检查第二次是否停止。

修复:只比较同版本fingerprint,显示时仍保留摘要;归一规则由taxonomy维护。

故障案例 3

返回结果后来被UI改写。

症状:Trace回放时历史消息或顺序与任务完成时不同。

根因:返回内部history数组和原failure引用,消费者排序或附加展示字段。

定位:调用后尝试push、sort和修改条目,检查Object.isFrozen及原值。

修复:finish统一复制并冻结条目与数组,类型对外声明readonly。

课后作业

加入指数退避、总 wall-clock 预算和 recovery strategy 标签;写测试证明取消可中断退避,且相同策略不能对同一指纹重复使用。

进阶要求是把attempt、strategyTag、fingerprintVersion、耗时和成本纳入冻结事件历史,为不同kind配置最大次数与退避。实现会话级retry budget和circuit breaker,证明并发任务共享故障时不会各自形成重试风暴。

验收 Rubric

维度通过标准常见扣分
可重试性非 retryable 一次即停对所有失败重试
重复连续指纹达到阈值停止只看消息或不重置
预算最大次数有严格上界多执行一次操作
历史有序、复制、冻结返回内部可变数组

总项目增量

总项目包:@coding-agent/agent-core

总项目路径:packages/agent-core/src/recovery-loop.ts

总项目验证命令:pnpm --filter @coding-agent/agent-core test

Week 4 完成编辑—回滚—Diff—分类—恢复闭环。Week 5 将把过程持久化为任务、会话与检查点。

延伸阅读

方案对比与工程取舍

固定次数循环最简单,却无视类别与进展;指数退避适合暂时服务故障,不适合代码断言;circuit breaker在共享服务故障时阻止所有任务继续;指纹重复检测针对单任务无进展。生产系统通常组合:分类控制资格,策略标签控制变化,attempt/wall-clock/cost限制资源,breaker控制全局故障。

最大attempt是最直观终止证明,但每轮成本差异巨大。还需要总模型token、工具时间、金钱和补丁次数预算;任一耗尽都停止。多维预算由可信配置给出,operation只能报告消耗,不能自行增加上限。

连续fingerprint检测比全历史集合允许回到旧问题。例如A类型错误修成B断言,修B后又出现A,可能说明回归,也可能是循环;全局策略可记录strategy与状态hash进一步判断。课程只判断连续重复,避免过早合并不同阶段。

strategyTag描述本轮采取的方法,例如“补类型注解”或“修改测试预期”。相同fingerprint再次出现时,如果strategy也相同,立即停止更合理;不同策略可允许有限尝试。tag由规划器结构化生成并进入历史,不能只靠自然语言比较。

操作成功后history仍有价值。它展示先失败后如何恢复,可用于评测策略质量、计算浪费与训练数据。成功不是删除失败证据的理由;但长期存储遵循脱敏与保留策略,history里只放安全摘要和fingerprint。

不可重试失败若外部事实变化,可以启动新Recovery session。例如用户批准权限后,新session拥有新policy version;不要在旧循环原地翻转failure.retryable,这会修改历史事实。会话边界让审计清楚“什么改变后重新开始”。

取消应在operation与退避两处生效。AbortSignal已取消时不开始新attempt;等待期间使用可取消timer;operation内部由Week3 Executor响应signal。取消返回cancelled非重试失败并保留已发生历史,后台进程真正回收后才完成。

退避公式常用指数加jitter,防止多个任务同步重试。jitter需要可注入随机源或时钟,测试才能确定;等待计入wall-clock。test/typecheck类别通常不等待,而是立即生成新策略,tool/timeout才使用退避映射。

Retry-After来自服务端时可以作为建议,但不能超过总deadline或用户上限。等待过长时直接停止并显示可稍后新建任务。不要让外部服务通过巨大值占住worker资源,排队状态也应可取消。

circuit breaker按工具或Provider fingerprint聚合。短时间大量相同tool失败后打开,后续任务不真正调用,快速返回不可重试或deferred;冷却后半开探测。单任务Recovery Loop无需实现,但Trace字段应支持上层识别共享故障。

幂等性决定能否重试。读取和测试通常可重跑,写入、发布、支付等即使tool类别暂时故障也可能已经产生副作用。operation合同应带idempotency key或提交证据,未知完成状态默认不重试。retryable类别不能覆盖动作语义。

补丁恢复每轮必须从可知工作区开始。失败后事务已回滚或保留明确快照,下一attempt重新读取上下文;若工作区处于rollback_failed,循环停止。不能在半补丁上继续模型修复,否则fingerprint变化也不代表进展。

历史条目可扩展为attempt、startedAt、duration、strategyTag、failure、cost和workspaceHash。规范序列化后可回放控制决策。时间戳不进入fingerprint,但进入事件;数组按attempt自然有序,不允许UI原地排序。

冻结是浅层还是深层要明确。Lab冻结数组与每个flat failure,若details嵌套仍可变,生产先构造可序列化深拷贝再深冻结,或只保存不可变标量。对任意插件对象直接递归冻结可能触发getter,边界先做schema解析。

operation返回失败对象后可能继续被其他组件持有,history push必须复制。测试可让同一对象在下一轮修改message,历史第一条仍不变。引用隔离与freeze共同保护事实,单独freeze调用方对象可能造成意外副作用。

停止reason应稳定且可本地化。机器使用non_retryable、repeated_failure、max_attempts、未来budget_exhausted/cancelled;UI根据kind和history生成说明。不要让模型自由文本决定控制分支,也不要只返回“失败”。

maxAttempts耗尽时最后一条失败已记录,operation调用次数严格等于上限。常见off-by-one是先递增再检查或把maxRetries当attempt。表格列出配置一、二、三对应调用数,用测试锁住。命名宁可用maxAttempts减少歧义。

配置阈值大于maxAttempts时,重复条件可能永远不先触发,这是合法的,最终reason为max_attempts。阈值一时首个retryable失败就repeated;产品可额外要求至少二,但算法合同应明确。验证只拒绝非正整数。

并发attempt通常不适合,因为第二轮依赖第一轮失败信息;任何并行“多策略竞速”是不同编排器,需要隔离工作区和胜者提交协议。Recovery Loop保持串行,简化副作用和历史顺序。

测试使用mockResolvedValueOnce表达失败后成功,固定相同fingerprint表达重复,按attempt生成不同fingerprint表达max。再用fake timers验证退避与取消,属性测试证明调用次数永不超过max。门禁应能杀死无界while、漏retryable和off-by-one实现。

评测指标包括成功前平均attempt、重复停止率、非重试占比、每类成本、无效同策略次数和用户接管率。更高恢复成功率若以十倍成本和副作用换取未必更好。目标是有界、可解释地恢复,而不是坚持到偶然成功。

先走一次失败后成功。attempt一返回retryable test失败A,循环复制入history,重复计数一,尚未达阈值;attempt二返回value done,finish冻结一条历史并返回completed。调用次数二,成功值保留,用户仍能看到第一轮为何失败。这个路径证明恢复不是把失败证据抹掉。

permission路径在attempt一返回retryable false。history先加入denied,再立即non_retryable,operation只调用一次,即使maxAttempts为五。若测试只看reason不看调用次数,错误实现可能已经多触发审批后仍返回同一reason,负副作用证据因此必不可少。

重复路径中operation始终返回fingerprint same,threshold二。第一次计数一继续,第二次计数二停止,history有两条,调用次数二而不是五。若threshold三则允许三次。停止发生在记录第二/第三条之后,UI可以展示连续证据。

变化失败路径按attempt返回一、二、三不同fingerprint,重复计数每次重置为一,最终for循环耗尽,reason max_attempts,恰好三次。这个用例锁住最大上限与连续重复语义,防止实现用全局Set在第三次错误地判重复。

A、B、A的专门测试更直观。第三条A虽然历史中出现过,但前一条是B,说明策略路径发生变化,连续计数应是一;若还有第四条A才达到二。全局振荡检测可作为更高层策略,但不能偷偷改变本课合同。

阈值一的结果值得写测试:第一个retryable失败记录后立即repeated_failure,operation一次。这看起来与non_retryable相似,原因仍不同,说明配置主动禁止任何相同失败机会。产品UI可以把阈值最低限制二,但算法验证保持一致。

maxAttempts一且retryable失败时,重复阈值默认二未达,循环耗尽返回max_attempts。检查顺序决定reason;不要在最后一轮前就返回max而漏历史,也不要循环后再额外调用。边界表能消除口头“重试一次”的歧义。

历史冻结测试还要修改原failure。operation创建对象F,返回后外部将F.message改成changed,result.history仍保存旧值;随后push或修改history条目应抛或无效。只有复制加冻结同时完成,才能抵抗两种引用污染。

若operation在成功前产生文件副作用,每个失败路径必须由内部事务决定回滚或提交。Recovery Loop不自动撤销,因为它不知道领域;它只在下一attempt前要求operation合同保证可重入。将回滚责任写在operation接口文档,避免控制层误以为失败天然无状态。

同一测试失败但workspaceHash变化,fingerprint仍相同是否应停止?可能新补丁确实不同但没有解决问题。默认重复停止保护成本;更精细策略比较strategyTag与workspaceHash,允许少量不同策略。所有扩展仍受maxAttempts与总预算,不能为“有变化”取消上界。

相反,fingerprint变化也可能只是归一不足,例如随机端口。Failure Taxonomy负责提高稳定性,Recovery Loop不二次处理message。若线上max_attempts高而repeated低,先审查fingerprint质量,而不是无限提高次数。

总wall-clock从recover开始到finish,包括operation和退避。每轮开始前计算剩余deadline,传给Executor与模型;operation不能获得比剩余更多的timeout。只在循环头检查会让最后一次跨过总预算,底层也需使用deadline。

成本预算同样先预留。若模型调用预计最低成本超过剩余,不开始attempt,返回budget_exhausted并保留已有history。已经发生的费用写入事件,不通过四舍五入隐藏。用户提高预算启动新session,旧事实不修改。

退避期间服务恢复与用户取消都可能发生。可取消timer在signal触发时立即结束并分类cancelled,不开始下一轮;系统关闭也能快速回收任务。普通sleep会占worker且无法响应,属于长运行Agent常见泄漏。

jitter测试通过注入随机函数固定零点五,时钟用fake timer,不真实等待。计算结果在上限内、单调增长且受deadline裁剪。随机性只影响等待,不影响attempt编号和history顺序,回放可记录实际delay。

全局breaker打开时,operation可以立即返回结构化tool failure但retryable false或专门deferred。若仍标true,单任务会在断路器上反复快速失败。控制层间的协议要明确,避免两个独立重试机制相乘成风暴。

Provider SDK可能自带重试,外层Recovery再重试会放大实际调用数。封装层应关闭或暴露内部attempt,把总次数纳入同一预算。用户看到的attempt二不应暗中代表六次网络请求,Trace要记录嵌套重试。

HTTP幂等key、补丁transaction id和工具call id都应跨暂时重试保持,防止第一次其实成功但响应丢失后重复副作用。策略发生实质变化则使用新id。Recovery Loop可把attempt Context传给operation,领域层决定key生命周期。

用户可在停止后查看“为什么没有继续”:non_retryable显示需要授权或新输入,repeated显示连续相同失败与最后策略,max显示资源上限。可行动说明比“重试失败”更能建立信任,也帮助用户选择扩大范围、修改目标或接管。

最后做一次故障演练:暂时tool失败后成功、permission立即停、相同test两次停、变化失败到上限、取消中断退避、operation抛异常归类、历史篡改失败。记录每条调用次数与成本,证明无路径超预算或产生后台调用。恢复系统的质量主要由停止路径证明。

演练还应检查进程重启后的接续语义。若history尚未持久化,本周实现只能保证单进程内有界;Week5检查点将保存attempt与冻结失败事件,恢复时从下一个合法状态继续,而不是把旧副作用重新执行。文章明确当前边界,避免把内存循环误称为持久化工作流。

在用户体验层,停止不是失败的反义词。面对权限拒绝、重复无进展或预算耗尽,及时停止并交付完整证据往往比继续猜测更正确。Agent应显示最后尝试、停止规则、已改动与回滚状态、用户可选下一步,让接管成本最小。

团队可以为每个任务保存恢复摘要:总attempt、各类fingerprint、策略变化、成功或停止原因、时间与成本。按周分析高频repeated_failure,改进上下文和策略;分析non_retryable,改善审批与输入校验;分析max_attempts,判断fingerprint或规划是否失效。历史不仅服务单次调试,也推动系统迭代。

最终发布门禁必须证明四件事:operation调用永不超过配置,非retryable只执行一次,连续重复在阈值处准确停止,所有路径返回不可变有序历史。只要有一项没有直接测试,恢复循环就仍可能成为成本和副作用放大器。

下一课衔接

Week4完成安全编辑与恢复闭环:唯一替换和补丁事务约束修改,Git Diff独立观察事实,Failure Taxonomy归一错误,Recovery Loop证明停止。Week5将把任务、会话、事件和检查点持久化,使进程重启后仍能恢复上下文,并让这些冻结历史成为可回放状态。

  • Exponential backoff、jitter 与 retry budget。
  • Livelock 和 circuit breaker。
  • 不可变事件历史与可重放系统。

从零实现 Mini Code Agent Runtime