← 返回全部文章

审查一轮 Agent PR 批量修复:27 个拉取请求、四类 bug,以及一次合并丢掉的那一行

发布于:
candyrushagent-runevidenceunity

2025 年 11 月 18 日,26 个拉取请求在四分十九秒内针对 CandyRush 开出 (#117 至 #142,UTC 08:23:42 至 08:28:01),第 27 个(#143)在 11 月 20 日跟进。 每一个都来自一个 AI agent,各自在独立的 claude/* 分支上,并且每个只做一件事: 修一个对象池 bug、替换一次查找、取消订阅事件处理器、收紧一个 DI 注册、修拼写、 补测试。

这轮批量修复由我指挥,落地什么由我把关。结果如下,全部来自 GitHub API 和 git log:

数量
开出的拉取请求27
按原样合并19(11 月 20 日 3 个,11 月 21 日两分钟内 16 个)
因合并冲突关闭,再重新组合进 #1448
11 月 22 日为让合并后的整体能编译、能运行而做的后续提交21
合并后发现的运行时回归1(敌人波次不再生成)

这轮修复解决了四类真实的 bug。但更有用的教训在于闸门本身:每个 diff 单独看都 说得通,问题出在组合上。

四类 bug

多建了一个对象的对象池(#126)

对象池的构造函数在 i 小于或等于 startingSize 时继续循环,所以配置为 50 的池 会建出 51 个。修复是两处各改一个字符:

-            for (int i = 0; i <= startingSize; i++)
+            for (int i = 0; i < startingSize; i++)

同一个 PR 还按池对象缓存了 GetComponent 的结果,并新增了 293 行 EditMode 测试, 断言精确的实体数量。PR 描述声称缓存后组件查找"快 50-100 倍"。没有人测量过;这是 agent 的估计。修复和缓存都还留在对象池里,而对象池现在位于我共享的 UTools 包中。

被标成 O(n³) 的查找(#131)

HasEvolution 为升级卡片回答"这个能力能否进化,它和谁配对?"。旧版本遍历每个 能力,再遍历每个进化的需求,再把需求遍历一遍。这个 PR 把它换成了初始化时构建一次 的字典。

标题写着从 O(n³) 到 O(1)。重新读一遍,这说法夸大了:每个进化都只有两个需求,所以 内层循环是常数级的,旧代码对能力数量是线性的,而且有提前返回。字典仍然是更好的 结构。PR 正文里"预期的帧时间改善"从未被测量。

这个 diff 还悄悄改变了一个行为。旧循环返回第一个匹配;字典保留的是最后一次写入。 如果某个能力同时是两个进化的需求,答案就会变。这个 PR 对这两种情况都没有加测试。

比窗口活得更久的事件处理器(#123)

有几个 UI 窗口订阅了事件却从不取消订阅,所以已销毁的窗口仍能被发布者引用,并可能 在销毁后收到回调。修复给七个 UI 行为(GameScreen、Chest、Lobby、Settings、 Upgrades、Characters,以及 Boss 血条)加上了 OnDestroy 清理,另外加了一个编辑器 工具,扫描脚本中有 += 却没有对应 -= 的地方。

PR 正文说"10 个关卡后有 1000+ 个悬空引用,内存持续增长直到崩溃"。那是描述里的 推算,不是测量,我不会把它当事实重复。清理本身无论如何都是对的。那个扫描工具在 2026 年 9 月的一次工具精简中被删除(#949)。

返回 null 的 DI 工厂(#137)

组合根用一个工厂注册 IUpgradesManager,当配置资源缺失时,这个工厂记录一条警告 然后返回 null。之后每个使用方都会在远离原因的地方抛出 NullReferenceException。修复把判断反过来,在启动时直接抛异常:

if (upgradesConfig?.upgradesDatabase == null)
{
    throw new System.InvalidOperationException(
        "[RootLifetimeScope] UpgradesConfiguration or database not found ...");
}
return new UpgradesService(saveManager, upgradesConfig.upgradesDatabase);

新增六行,删除五行。DI 容器永远不应该为已注册的服务交出 null;在组合阶段失败, 才能指出真正的问题。

这轮修复的其余部分是代码卫生:用层常量代替魔法数字(#117)、平台 Strategy 模式 (#118)、编辑器脚本保护(#127)、接口覆盖测试(#129,336 行)、PlayMode DI 集成测试(#134,851 行)、空值运算符现代化(#133)、类名拼写修正(#138)、 43 个 ScriptableObject 文件上的校验特性(#141)、XML 文档(#142),以及一个 .editorconfig(#143)。

闸门拦住了什么

CI 分不出好坏

在这一批里,我检查过的每个拉取请求上 Unity 测试 job 都失败了,包括 #137 那个只有 六行的修复。对所有东西都是红的检查,什么也拦不住。真正的闸门是编辑器:合并、 编译、试玩。

没能挺过碰撞的八个 PR

前 16 个合并之后,剩下的 PR 中有 8 个已经无法干净应用。每一个都在关闭时留言说明 工作已被记录、留待以后。当天下午,它们被 cherry-pick 进一个合并用的 PR,#144 (+1,280/-948,76 个文件),冲突手工解决。它的描述列出了所携带的破坏性变更:一次 公共 API 重命名(Aquired 改为 Acquired,103 处),以及 lifetime scope 现在需要 在 Inspector 中指定资源,而不是用 Resources.Load。

部分都说得通,整体却编译不过

11 月 22 日,一个 agent 会话逐一处理了组合带来的破坏。这些提交读起来像一份目录, 列出了任何单个 PR 都无法暴露的相互作用:

  • #126 给对象池加了 using System;,使 Object.Instantiate 和 Object.Destroy 在 System.Object 与 UnityEngine.Object 之间产生歧义。
  • #121 引入了一个 AnimationTriggers 常量类,它在宝箱窗口中与 UnityEngine.UI.AnimationTriggers 冲突。
  • #120 把 WaveData 的字段改成了属性;生成器和两个测试文件仍在使用字段。
  • 一个 Boss 行为最终出现了重复的字段和重复的 Construct 方法。

总共二十一个提交,其中包括删除 #132 标记过的 [Obsolete] 代码、完成从 Resources.Load 的迁移,以及清除所有编译警告。

被一次合并丢掉的那一行

影响最大的那个问题,编译完全通过。

#122 把 Timeline playable 行为里的服务定位器换成了 VContainer 注入,并在 GameLifetimeScope 中注册了新的 ITimelineBehaviorInjector。在手工解决 #144 的 冲突时,这唯一的一行注册丢了。我沿着历史追踪了它:

2273228d3  #122 merged           GameLifetimeScope registers ITimelineBehaviorInjector: yes
bb3128679  #144 merged           registration: no
c142d6d7e  Resources migration   registration: no
707c08c18  fix commit (Nov 22)   registration: yes

编译时什么都没失败。运行时,波次行为拿不到依赖。恢复这一行的提交记录了症状: Timeline 行为注入失败,没有敌人生成。编译器看不到缺失的注册;运行游戏才能看到。 修复就是把那一行加回去。

我的收获

单一职责的小 PR 容易审查,却难以组合。 这些 PR 每个一分钟就能读完。成本转移到 了合并环节,而在那里没有人会以同样的认真去读 diff。手工解决冲突的合并本身就是一次 新的变更,应当得到与它所替代的 PR 同样的审查。

PR 正文里的说法是假设。 "50-100 倍"、"O(n³)"、"1000+ 个悬空引用":每一个都 出自变更的作者之手,没有一个经过测量。现在我把描述里未经测量的数字当作要么验证、 要么删除的东西。

红色的闸门不是闸门。 每个 PR 上的 CI 都失败时,唯一的信号就是编译和试玩。让 测试套件变绿、变快,后来成了一个单独的项目;见 流水线重构。

组合根需要运行时检查。 缺失的注册照样能编译。在这轮修复里,这个缺口表现为 "没有敌人",而这类失败本应由容器校验测试或冒烟测试来负责,而不是靠人。

在根部尽早失败。 #137 是值得保留的模式:一个在启动时抛出异常并给出缺失资源路径 的工厂,比一个记录日志后返回 null 的工厂更有价值。