构建没跑完,别急着换脑子

一次自动修复协议的调整:没完成,不等于做不出来。
1. 换个模型,有时只是换个人重新排队⌗
设想一个自动修复任务:代码已经改好,针对问题的测试也已准备好,接下来需要等待测试资源。轮次结束了,最终验证却没完成。
调度器看见一个失败状态,启动下一个候选模型。新模型重新读问题、重新定位、重新实现,然后走到同一条资源队列前。
看起来系统在积极尝试,实际上只是把一次执行阻塞包装成了模型选拔。
这里至少混着三个不同判断:
| 观察到的事实 | 能得出的结论 | 不能直接得出的结论 |
|---|---|---|
| 测试环境不可用 | 当前无法完成验证 | 方案错误、模型无能 |
| 落地检查拒绝提交 | 当前提交尚不能落地 | 原作者已无法修正 |
| 一轮任务结束但没有交付 | 交付协议尚未闭环 | 候选方案应该被淘汰 |
这次改动的重点不是让重试更勤快,而是让系统不再把这三件事误记成同一件事。
恢复执行和更换候选,应该是两条控制流。
2. 「还是同一个模型」不够⌗
改动前,环境故障已经有特殊待遇:记录故障原因,退避后由同一个模型再试。
问题在于,再试调用的是启动新修复的路径。保住模型名称,没有保住原任务及其工作现场。
新实现保留的是一组身份:
同一修复 run
├── 同一任务线程及其历史
├── 同一模型
├── 同一 worktree
└── 同一分支与已有提交
原 run 保持 running,对应问题保持 fixing。等待恢复通过动作记录中的 pendingResume 表达,而不是先把修复结案,再启动一份相似任务。
这很像编译失败之后继续修改原工程,而不是再找一位工程师,让他从需求说明重新建项目。
| 路径 | 新建修复任务 | 原现场是否是继续工作的起点 |
|---|---|---|
| 旧的环境重试 | 是 | 不再直接续跑原任务 |
| 新的原地续跑 | 否 | 是,线程、工作区、分支一起保留 |
| 明确放弃后的候选交棒 | 是 | 进入下一个候选的修复流程 |
差别不是命名。已有代码、排查历史和验证进度,都是修复中的资产。
3. 失败日志之外,还需要一个继续执行的协议⌗
源码将两条路径区分为两种意图。
下面是对状态含义的简化表示,不是完整数据结构:
执行中断 / 环境失败 / 落地拒绝
pendingResume = true
pendingNext = false
→ 原任务继续
明确报告无法完成候选方案
pendingNext = true
retryMode = "next-model"
→ 下一个候选
显式拒绝任务也沿用候选交棒策略。相反,任务仅仅结束、停止,或者没有提交最终报告,不再自动证明应该换模型。
续跑时,系统不是只发一句「请重试」。消息包含:
- 原修复身份、工作区和分支;
- 上次为什么中断;
- 已记录的诊断信息;
- 保留现有实现、从验证现场继续的要求;
- 再次提交仍使用原修复身份的要求。
若失败原因是合并冲突,则要求在原分支合入最新主线并解决冲突。
这比「失败了,换个模型看看」多了一项责任划分:落地通道负责给出拒绝证据,原修复任务先负责消化证据。红灯是反馈,不是自动解雇通知。
还有一个容易遗漏的细节:旧实现保存落地失败步骤时,将每一步整理成短字符串,并截取诊断内容。新实现保留失败步骤对象,再将其送回原任务。只有「验证失败」四个字的反馈,不能指望模型凭空知道该从哪里接着修。
4. 不要让清理器把等待中的任务扫掉⌗
只改续跑入口不够。自动化系统里往往还有另一位勤劳同事:清理超时任务的生命周期扫描器。
如果它只看最初启动时间,任务等待环境恢复之后,刚被唤醒就可能再次被判定为陈旧。
这次改动让相关扫描认识续跑状态:
- 正在等待续跑的修复,不按普通失联任务处理。
- 已恢复执行的修复,超时判断参考最近的续跑记录时间,而不只看最初启动时间。
- 一个失联修复被识别为基础设施中断时,先排入原任务续跑,而不是直接结束并另开修复。
调度侧也补上对应约束:原任务还活跃时不重复续跑,同一运行实例中重叠的推进调用不重复派发。人工暂停的记录不会被自动调度重新激活。
旧记录还有一个保守处理:只有旧式 pendingNext、没有新协议 retryMode 的记录,不会被分钟调度器直接当成换模型指令。
恢复能力不是增加一个 resume 函数,而是让启动、回收、暂停和失败记账对同一份状态达成一致。
5. 环境没好,就先别叫醒模型⌗
旧机制使用退避时间;新路径在基础设施或测试资源故障后,先做环境就绪探测。
探测失败,继续等待,不启动新的模型轮次。之后的分钟调度再检查。
这不是保证下一轮测试一定成功。生产接线中的探测调用远端测试环境探针;它只提供一个就绪信号,实际资源等待仍由原任务承担。合并冲突也不靠这个探针解决,而是直接交回原分支处理。
不过,它避免了一个明显浪费:让模型消耗一轮上下文,只为了再次得知环境依然不可用。
续跑也不是无限的。源码中的 FIX_RESUME_LIMIT 为 3,计数针对实际发起的这类续跑;环境探测未通过,不会提前消耗次数。
达到上限后,修复退回分诊,保留工作区和证据,不自动进入下一个模型。原任务身份缺失时,同样保留现场交回分诊,而不是悄悄从头开工。
三次是本实现的策略值,不是可靠性的理论常数。它表达的是一个取舍:允许有限的原地恢复,但不让自动调度无限占着修复;次数耗尽也不能被偷换成「模型能力已经被证伪」。
6. 验证分工,不是「所有测试永远只跑一次」⌗
这次改动还收拢了验证职责。修复任务的提示明确要求:
- 运行复现问题的明确测试文件;
- 运行直接修改模块的测试;
- 不在工作区重复运行整套测试和工作区类型检查;
- 最终验证交给落地通道。
这解决的是重复劳动,不是降低验收标准。
修复任务
修改 → 针对性测试 → 提交
│
▼
落地通道
│
失败证据 ──┘
│
▼
原任务继续修
如果落地验证暴露新错误,修复代码后仍然需要重新验证。因此,「只验一次」不应理解成某个测试进程只能启动一次,更不意味着跳过回归检查。
真正值得保留的规则是:不要让每一层都为了自证认真,再把同一套昂贵检查完整跑一遍。
同时,修复任务的这部分范围约束是通过提示传达的,不是某种能禁止一切额外测试命令的执行沙箱。调度机制和模型遵循指令,仍是两种不同保证。
7. 测试证明了什么⌗
以下依据是该提交中的测试代码断言,写作期间没有重新执行测试,也没有生产性能测量。
| 测试场景 | 检查的性质 |
|---|---|
| 落地失败,返回较长诊断,再次提交 | 诊断进入原任务消息;两次提交使用同一分支,最终可转为解决 |
| 重复收到同一落地失败记录 | 不重复排入续跑;旧失败也不干扰后续提交 |
| 冲突与重叠推进调用 | 原分支合入主线;重叠调用只派发一次续跑 |
| 测试环境未就绪 | 不发起续跑任务 |
| 环境恢复后多次续跑 | 只有一份初始任务;续跑始终使用同一修复身份与工作区 |
| 达到续跑上限 | 不自动切换候选;已有提交仍可从工作区读到 |
| 等待很久后恢复,再做生命周期扫描 | 不因最初启动时间久远而立即误杀 |
| 暂停记录与旧协议记录 | 分钟调度不会将其自动重新启动 |
| 明确报告无法完成 | 下一候选模型仍正常接手 |
最后一行同样重要。新设计不是「永远不换模型」,而是要求换模型有对应证据。
测试使用可控的任务运行与续跑依赖,能检查状态、调用次数、消息和分支关系;它们不能证明真实模型一定会正确继承全部思路,也没有证明跨进程故障下的端到端恰好执行一次。
因此,可以说这次改动减少了机制上不必要的重新开工,不能据此宣称节省了多少 token、提高了多少成功率。没有测量,就别给收益编一个漂亮百分比。
8. 别把调度问题写进模型成绩单⌗
「失败就重试,再失败就换模型」很容易实现,也很容易把系统自身的问题藏起来。
真正的进步是把两个轴拆开:
- 执行轴:环境是否可用、任务是否中断、提交是否进入下一阶段;
- 求解轴:当前候选是否仍能修正方案、是否明确放弃。
执行轴暂时走不动,优先保留现场;求解轴明确交棒,再更换候选。
保留原任务也有代价:它会继续携带旧上下文,原作者可能困在原先的思路里。这正是仍然保留显式交棒和有限续跑的原因。恢复不是无限信任原方案,而是先给它处理新证据的机会。
自动修复不缺另起炉灶的能力。缺的是知道什么时候根本不该另起炉灶。
源码依据:提交 b08cce8 的 src/improve/autofix.ts、src/bootstrap/improve.ts、src/improve/triage.ts 与 src/stability/immediate.ts。测试依据:src/improve/autofix.test.ts、src/improve/fix-wait.test.ts 与 src/stability/immediate.test.ts。