ERICH TECHNICAL NOTES / 02 返回 Notes
AI DEVFLOW / ENGINEERING JOURNAL

SAFETY NET

从测试通过到可信交付:DevFlow 的 AI 开发防护网

从三个真实故障出发,连接分层测试、修复反馈与发布验收。

在 DevFlow 的一次真实运行里,OpenCode 连续两次写出了同一个有缺陷的测试:新增的 UI 测试没有清理 DOM,页面上于是出现了两个“生成计划”按钮,测试报错。产品两次都正确地拒收了结果,第三次重试却依然可能犯同样的错,因为交给它的输入里只有一句 Tests failed with exit code 1。

测试已经发现了问题,修复却原地打转。这件事让我重新想了一遍“测试防护网”这个说法。最初我想写的是:有了 smoke、Electron、Playwright 和 computer use,AI 开发中的大部分问题都能兜住。回头翻 Issue 才发现这句话我证明不了。能证明的是更具体的几件事:每一层检查到底覆盖了什么,失败信息有没有回到修复者手里,最后交付的是不是被验证过的那一份。

下面以 DevFlow 自身的开发与发布为主线,复盘三个真实故障和一条发布链。第四章讨论的是 DevFlow 产品内对用户项目的测试,会单独说明。

先分清每一层验证在回答什么

DevFlow 由桌面应用、Web 控制台、API、Postgres 和外部 Agent 进程组成。一段逻辑在单元测试里正确,推不出这些部分接在一起也正确。所以我更关心的不是测试数量,而是每一层回答了什么问题,又把什么留给了下一层。

验证环节 主要回答的问题 还需要别的证据
领域与组件测试(Vitest) 状态转换、权限规则和界面动作是否符合约定? 接入真实数据库和外部进程之后的行为。
API 与真实 Postgres(Docker) 迁移、约束和持久化是否按预期工作? 用户在完整界面中的操作。
浏览器 E2E(Playwright) 预先写好的页面路径和结果是否保持正常? 脚本之外的新路径和使用困惑。
打包后的桌面冒烟 打包产物的主进程、preload、界面和本地存储能否一起运行? 真实模型与远端服务的表现,这一层连接的是本地 fake API。
独立的真实任务验收 指定的候选包能否按正常操作完成一次任务? 其他需求、环境和长期运行。

这几项并不互斥。“冒烟”描述检查范围,Electron 是运行环境,Playwright 是驱动工具:打包冒烟脚本(在新标签页打开来源)用 Playwright 的 _electron 启动打包后的可执行文件,开发态冒烟(在新标签页打开来源)则从源码目录启动并开启 fake runtime。测试策略(在新标签页打开来源)按层记录了这些边界。

受控的测试替身适合稳定复现某个分支,比如用本地 fake API 检查桌面进程之间的通信,避免网络波动掩盖问题。它证明的是那条受控路径;真实 DeepSeek 或 OpenCode 的表现,需要真实调用留下的证据来回答。

漏报:内存里通过,Postgres 里冲突

一次真实的 OpenCode/DeepSeek 流程中,Gate Review 完成后,下一次同版本的 Run 摘要同步返回了 HTTP 409。基于内存和种子数据的 Electron 冒烟没有复现,Issue #93(在新标签页打开来源) 把范围限定到了 PostgreSQL。

问题藏在两条 upsert 语句里。上传子节点的审查摘要时,代码用一个占位状态 blocked 写入节点,冲突时顺手把已有的权威状态覆盖掉了:

-- 修复前
ON CONFLICT (id) DO UPDATE
SET subtitle = excluded.subtitle,
    status = excluded.status,
    updated_at = excluded.updated_at

-- 修复后
-- Review advice must not mutate the canonical Workflow status.
ON CONFLICT (id) DO UPDATE
SET subtitle = excluded.subtitle,
    updated_at = excluded.updated_at

下一次同步带着原来的节点状态回来,hasSameRemoteRunProjection 发现两份状态不一致,于是拒绝写入。409 本身是对的,错误发生在更早的那次覆盖。测试证据的 upsert 有同样的写法,修复(在新标签页打开来源)一并去掉了。

补的回归放在真实 Postgres 上:同版本重放两次都应成功;如果真的改了节点状态,仍然必须返回 409。回归用例(在新标签页打开来源)既防止旧问题回来,也防止有人为了让流程通过而放松冲突检查。

这次漏报让我改了补测试的顺序:先找到产生差异的那条真实边界,再决定回归放在哪一层。给内存实现再加十个相似的用例,也看不到这条 SQL。

误报:一个测试里有两只钟

防护网本身也会出错。准备 v2.3 候选版本时,一个 OpenCode 执行时限的用例失败了,报出的是 opencode_wall_clock_limit_exceeded,而它本该触发的是有界的权限发现超时。Issue #122(在新标签页打开来源)

原因在测试夹具的构造顺序。适配器在创建时拿到当时的 Date.now 函数引用,测试却是先创建适配器、再调用 vi.useFakeTimers():

// 适配器:构造时取得时钟函数
const nowMs = config.nowMs ?? Date.now

// 修复后的测试夹具:会话时限和轮询共用同一只虚拟时钟
nowMs: () => Date.now(),

结果是轮询在推进虚拟时钟,时限判断却在看真实时钟,一个只有 35 毫秒的时限混用了两种时间。另一个问题与并发有关:高负载下,一个 UI 用例撞上了 5 秒的默认时限,而它所在的 145 个用例的测试文件在限制为两个 worker 后能稳定通过。

修复(在新标签页打开来源)只动测试环境:夹具注入统一的时钟,vitest.config.ts 设置 maxWorkers: 2,默认的 5 秒测试时限和产品本身的执行时限都没有放宽,已有用例和断言全部保留。

我希望失败信号能指向一个可以调查的问题。产品行为错了,就修产品;测试环境产生了不稳定的信号,就修测试环境,同时保留它原本要验证的约束。否则 AI 很容易围着一个噪声信号,反复修改其实正确的代码。

反馈:把失败交回下一次修复

回到开头的重复失败。这一章讨论的是 DevFlow 产品内对用户项目的正式测试。两次 Coding 都留下了 DOM 未清理的缺陷,正式测试也两次拒收了结果。Issue #101(在新标签页打开来源)

拦截这一步是有效的,缺口在下一步。修复前,重试简报里的测试证据只有一行摘要:

- <测试命令> [failed]: Tests failed with exit code 1

模型知道失败了,却没有拿到最有助于定位的那句报错。修复(在新标签页打开来源)之后,简报只挑选同一个 Run、同一个项目、同一条规范测试命令下的最新一条证据;只有它失败时,才附上脱敏后的诊断:

const latestCanonicalTest = input.testEvidence
  .filter((evidence) => evidence.runId === input.run.id &&
    evidence.projectId === input.project.id &&
    evidence.command === input.project.testCommand)
  .reduce(/* 取 createdAt 最新的一条 */)

日志先去掉 ANSI 控制符、脱敏,再截断到 2,000 个字符。诊断在简报中被标注为“不可信的历史数据”,并要求执行器先在新的受管工作树中复现,而不是把它当成指令。如果后来已经有一次成功覆盖了旧失败,旧错误就不再带入。对应的测试专门检查:别的 Run、项目或命令的诊断不会混进来,密钥和本地路径不会泄露,超长日志会被截断。

第三次编码拿到了这条诊断,给测试加上清理和一条文案断言,7 项测试、类型检查和构建全部通过。这次改动没有加入自动重试,也没有降低验收门槛,只是让下一次明确发起的修复拿到更完整的输入。

一份测试结果有没有价值,要看它能不能继续被使用:属于哪次任务,针对哪份代码,失败在哪里,下一轮是否已经修复。

交付:发布的必须是验收过的那一份

源码测试通过后,还有一个容易被忽略的问题:交到用户手里的安装包,是不是被验收过的那一份?版本号相同,并不能回答这个问题。

DevFlow 的发布工作流(在新标签页打开来源)把这件事写成了硬性步骤:

- name: Validate recorded Verify run against GitHub
  id: recorded-verify
  run: node scripts/validate-release-verify-run.mjs docs/releases/v2.3.0/release-required-gates.json

- uses: actions/download-artifact@v8
  with:
    name: ai-devflow-studio-candidate-desktop
    run-id: ${{ steps.recorded-verify.outputs.run-id }}

- name: Verify exact candidate Desktop artifact trio
  run: node scripts/desktop-artifact-trio.mjs verify out/release-candidate-desktop/artifact-index.json --exclusive

先确认记录中的 Verify 运行确实存在、由手动触发、对应的提交正是候选 SHA、所有 job 都成功;再按这次运行的 run-id 下载它产出的桌面归档,校验清单和 SHA-256;最后只从这份归档里暂存桌面产物。发布出去的包,可以沿候选提交、CI 运行和归档一路追查回去。

v2.3 的候选验收记录(在新标签页打开来源)也绑定在同一份 CI 桌面包上。记录列出 3,866 项测试和 41 项浏览器测试,并单独说明:使用受控 Provider 的冒烟不算付费 DeepSeek 的证据。这些数字各有各的范围,不能加总成一个“可靠性分数”。独立验收时,原生界面控制失败,经我同意改用 Playwright 驱动真实界面;GitHub 登录和二次验证由我协助完成;界面上看不到的底层计数,由事后的只读审计补充。

网之外的东西

自动检查全部通过之后,独立验收仍然发现了两个问题:一段没有阻塞性发现的正向 Review,被界面显示为“可选待修复”(#124(在新标签页打开来源));通过自动化菜单执行 Quit 后,应用进程还活着(#125(在新标签页打开来源))。当时的记录没有把后者下结论为普通退出也有问题,因为还分不清是自动化控制方式还是应用本身的原因。

这两个问题后来都查清了。#124 在 9 月 14 日当天修复,原因是“没有发现”时系统合成了一条低严重度的 review gap;#125 在 9 月 21 日修复,根因确实在应用侧:普通菜单退出在清理完成后又重入了一次 app.quit。两项修复都在 v2.3.0 发布之后才合入主干,所以 v2.3.0(在新标签页打开来源) 的安装包里并不包含它们。#124 修复(在新标签页打开来源) · #125 修复(在新标签页打开来源)

Gateway 提供了一个相邻的例子。一位外部部署者在 Windows 上发现,Agent 子进程选到了 WSL 的 bash 而不是 Git Bash,同时缺少 rg 和 fd;gateway doctor 的检查却显示正常。Gateway #12(在新标签页打开来源) 目前仍是打开状态,现场靠调整 PATH 绕过。它提醒我,进程能启动、自检能通过,不等于它需要的 shell 和工具都在。

所以我现在对防护网的期待很朴素:让真实故障反过来改进验证。先留下能复现的失败,再确认修复触及了原因,最后沿原来的任务路径重走一遍。测试负责提供证据、阻止已知的错误;修复仍需要人或 AI 来判断,还没覆盖到的部分要明确写出来。

我希望每一次“通过”都能回答三个问题:检查了什么,依据是什么,最后交付的是不是这一份。


本文基于 DevFlow 的公开 Issue、修复提交、测试策略、发布工作流与候选验收记录整理,后记部分核对到 2026 年 9 月 29 日。测试数量均引用对应的历史记录;本文写作时没有重新执行 DevFlow 的测试或付费模型任务。版本相关的链接固定到具体提交;现场账号、凭据、私有任务标识和本地路径未纳入文章。