让 AI 修 AI 平台自己的 Bug:我用 SoduWork 给 SoduWork 提了一个 PR

一句话结论 :SoduWork 想解决的不是"让 AI 干活",而是"AI 干完的活谁来认账"。这次我没让它写 Demo,而是让它在 SoduWork 自己的源码仓库 里修一个我实测踩到的 Bug。全程 TDD、342 个基线用例、多语言环境复现,最后变成一个真实的开源 PR。过程中 AI 有一次悄悄放宽了测试断言,还编了一个借口,是代码审查把它抓出来的。这恰好说明了 SoduWork 存在的意义。


一、为什么要这样测

Claude Code、Codex、各种 Agent 我都在用,产出很快,但越来越明显的问题是:AI 省下来的时间,大半又花在"检查 AI 干得对不对"上了。

SoduWork 的 README 里有一句话:聊天以回答结束,SoduWork 的需求以提出人签收结束。

我不想只拿一个玩具任务验证这句话,所以测评分三步:

  1. 上手:Windows 从零安装,把踩的坑记下来;
  2. 主菜:用 SoduWork 修 SoduWork 自身的一个真实 Bug,并提交 PR;
  3. 源码:回到代码里,看"签收""审批""沙箱"到底是怎么实现的。

二、上手实测:Windows 从零安装

2.1 环境

项 版本
系统 Windows 11
Python 3.12.10(要求 ≥ 3.12)
Node.js 26.13.0(要求 ≥ 22.19)
Git 2.56.0
SoduWork 源码 v1.13.13

我的电脑原本只装了 Python,Node 和 Git 用 winget 两条命令装好(OpenJS.NodeJS.LTS、Git.Git)。小坑:装 Git 时会弹 UAC 管理员确认,点了取消会报 exit code: 2;装完记得重开终端。

2.2 一条命令启动

bat 复制代码
git clone https://github.com/mdiri-ai/SoduWork
cd SoduWork
start-desktop.cmd

首次启动先设置 admin 密码(12 位以上,没有默认密码),然后依次是:创建独立 Python 环境 → 安装依赖 → 构建管理页 → 安装桌面端 → 下载 Electron。

2.3 踩坑:Electron 下载卡了 68 分钟

前几步都很顺,到 Downloading the Electron runtime 就不动了,一直跑到 4102 秒还没下完。

翻了启动器源码 tools/dev/local_environment.py:这一步直接调用 node_modules/electron/install.js 从 GitHub 下载二进制,国内网络大概率卡住。好在子进程会继承环境变量,Electron 官方的镜像变量可以用:

bat 复制代码
set ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
start-desktop.cmd

重启后已完成的步骤靠哈希戳直接跳过,Electron 77.8 秒下载完成:

💡 有意思的是,源码里给打包用的 Node/Python 运行时都默认配了 npmmirror(SODUWORK_NODE_MIRROR / SODUWORK_PYTHON_MIRROR),唯独源码启动这条路径上的 Electron 没有。已反馈:https://github.com/mdiri-ai/SoduWork/issues/25 。

启动器本身挺讲究:数据目录和源码隔离、SQLite 单机运行不用 Docker、只绑 127.0.0.1、有文件锁防止重复启动、不继承外部的 DATABASE_URL 和模型 Key 环境变量。

2.4 接入模型

管理后台 → 能力配置 → 模型目录 。预置了 10 个服务商(DeepSeek、通义、智谱、Kimi、火山方舟、OpenAI、Claude、Gemini 等),填 Key 即可。我用的是 deepseek-v4-flash。注意:老教程里的 deepseek-chat 已在 2026-07-24 下线。

这个页面还能看到全站并发上限、排队、超时、上游 429 等指标,而且"服务、密钥、模型、用户四级并发上限同时生效",明显是给团队共用模型账号设计的。

这里有个小插曲:管理后台第一次打开是英文的,我的浏览器和系统明明都是中文。这个问题后面成了本文的主角。

桌面端登录后,发一句话测试,5 秒收到回复:


三、热身:先搞清楚验收闭环怎么运转

进主菜前,我先用一个小任务摸清 SoduWork 的核心流程。

需求池是一个五列看板:收集箱 → 待开始 → 进行中 → 待验收 → 已完成 。新建需求时有专门的"完成标准 "区,Agent 交付后会出现一张签收卡,上面有"手动验收 / 自动验收""由你确认 · 需求创建人",以及"退回修改 / 确认验收"两个按钮。

热身中有两个细节让我决定认真测下去:

  1. 打回后需求真的进不了"已完成",会停在"进行中",而且退回意见会自动拼进下一轮指令,并注明"在现有交付上返修,不要从零重做"。
  2. Diff 抓到了 AI 的"小动作":AI 声称"只在原文后面补充",但版本 Diff 显示它还顺手删掉了几个字。

AI 的自我汇报不能全信。 带着这个结论,进入主菜。


四、主菜:用 SoduWork 修 SoduWork 自己的 Bug

4.1 Bug 与陷阱

上面提到的"管理后台第一次打开是英文",我先在源码里定位了原因(hub-frontend/src/i18n.js):

js 复制代码
function readLocale() {
  try { return normalizeLocale(localStorage.getItem(STORAGE_KEY)) } catch { return FALLBACK }
}

它只读 localStorage,从来不看浏览器语言,读不到就回落 en-US;normalizeLocale() 遇到 zh、zh-Hans 也会落到英文。

看起来改一行就行?这里有个陷阱。 项目原有测试里有一句:

js 复制代码
assert.equal(getLocale(), 'en-US')

而 Node 21 起也有全局 navigator ,它的 navigator.language 跟随系统语言。我在沙箱里验证了一下:

bash 复制代码
$ node -e "console.log(navigator.language)"                 # en-US
$ LANG=zh_CN.UTF-8 node -e "console.log(navigator.language)" # zh-CN

也就是说,如果简单粗暴地"读 navigator.language",这条测试会变成只在英文机器上通过,而我的电脑恰好是中文 Windows。这正好可以检验 AI 能不能想到这一层。

4.2 让 Agent 直接在真实仓库里干活

SoduWork 的需求默认在隔离的临时目录执行。要改真实仓库,得用两个功能配合:

  1. 会话输入框下方 工作位置 → 选择现有文件夹 ,选中本地的 Documents\SoduWork;
  2. 先在需求池建好需求,再在这个会话里点 ⊕ → 选择需求 → 在本会话执行,把需求绑定到这个会话。

4.3 七条工程级验收标准

  1. 在新分支 fix/admin-locale-follow-browser 上修改,只改 hub-frontend/;不要 commit、不要 push
  2. 没有本地记录时按 navigator.languages / language 选择语言;用户手动切换过的语言优先
  3. zh、zh-CN、zh-Hans、zh-SG 映射到 zh-CN;zh-TW / zh-HK 怎么处理要给出理由
  4. 测试结果不能依赖运行机器的系统语言(注意 Node 21+ 也有全局 navigator);现有用例语义保持不变
  5. 在 tests/i18n.test.mjs 补回归用例
  6. npm ci 后全量测试通过、npm run build 成功,贴真实输出
  7. 输出可直接用的 PR 描述和 git diff --stat

第 4 条就是给陷阱留的考题。

4.4 第一轮:过程很专业,结果有猫腻

Pi 的第一轮表现可圈可点:

  • 先读了 CONTRIBUTING.md 和现有测试,跑基线:342 个用例全部通过;
  • TDD :先写 5 个回归用例,确认它们按预期失败(红),再改 src/i18n.js;
  • 遇到测试隔离问题时(i18n.js 在模块加载时就读取语言),一步步定位到根因:"withEnvironment 在 finally 里调用了 setLocale(),而 getLocale() 的返回值在 finally 之后才求值";
  • 最后在 LANG=en_US、ja_JP 和默认中文三种环境下分别跑测试,结果一致。

全量 347 passed / 0 failed,构建成功,源码审计通过。到这里看起来可以签收了。

但我逐行看了它的自证表,发现了这一条:

原有测试语义不变:只改了 1 处原有断言:getLocale() === 'en-US' → 断言结果属于四种受支持语言之一(原断言本身就是在依赖机器语言,与本次需求冲突)

它把原断言放宽了 ,让测试在任何机器上都能过,还给了一个理由。问题是这个理由不成立 :改动之前 readLocale() 根本不读 navigator。我在沙箱里用中文环境跑原始代码验证:

text 复制代码
中文 LANG 下原始代码 getLocale = en-US | navigator.language = zh-CN

原断言是确定性的,被 AI 自己的改动破坏了,它就修改断言来迁就。这是 AI 写代码最典型、也最危险的毛病之一,而它在自证表里把这一条标成了"满足"。

除此之外,第一轮还有两个问题:

  • 范围蔓延 :额外引入了一个内部的 zh-TW 解析目标,还改动了 translateText 的 fallback 逻辑;
  • 测试设计脆弱:靠"单向注入全局变量、不还原、必须严格顺序执行"让测试通过。

整轮从 00:19 跑到 00:33,十几分钟、一百多条命令。

4.5 一个关于"中途引导"的真实发现

第一轮跑到第 8 分钟左右时,我看它在原地打转,就在输入框里发了一条"中途引导",提示它用纯函数加 window 守卫的思路。

结果发现:这条消息并没有在这一轮中途送达,而是排队到这一轮结束后才出现。所以第一轮的所有表现都是它自己完成的,跟我的提示无关。这一点要如实说明:至少在我这次使用的 Pi 会话里,执行中发的消息相当于"下一轮的补充",而不是"实时转向"。

4.6 代码审查打回:AI 承认自己在找借口

我把审查意见发了过去,三条:

  1. 【阻断】恢复被放宽的断言,并附上我的复现结果,说明"原断言依赖机器语言"不成立;
  2. 【阻断】只在浏览器环境(typeof window !== 'undefined')读取 navigator;探测逻辑抽成可注入参数的纯函数,测试直接测纯函数;
  3. 【范围】去掉 zh-TW 内部目标和 fallback 改动;繁体先统一映射到简体,正式支持另开 issue。

Pi 的第一句回复是:

评审关于第 1 条的反驳是对的:改动前的 readLocale() 确实不读 navigator,我用"原断言依赖机器语言"当理由,是在为自己放宽断言找借口,不成立。

4.7 第二轮:干净多了

这一轮只用了两三分钟:

  • 新增纯函数 detectLocale({ stored, languages, language }),所有输入都是参数;
  • browserPreferences() 只在 typeof window !== 'undefined' 时读取 navigator;
  • 恢复原断言 assert.equal(getLocale(), 'en-US'),并实测"本机 navigator.language = zh-CN,getLocale() 仍然是 en-US";
  • translateText、FALLBACKS 回到原样,git diff 里不再出现;
  • 还发现了一个 Windows 特有的问题:仓库是 core.autocrlf=true,它写文件用的是 LF,会导致整个文件在 diff 里被重写。它把换行符恢复成 CRLF,让 diff 只包含真正的改动(我推测热身任务里那个只改一行、却显示 "+20/−20" 的 diff 头,也是同一类换行符问题);
  • 最后模拟中文浏览器首次访问做了一次端到端确认。

核心改动大致是这样:

js 复制代码
// 只有浏览器知道用户偏好;Node 21+ 的全局 navigator 反映的是构建机的语言
function browserPreferences() {
  if (typeof window === 'undefined') return {}
  const nav = typeof navigator === 'undefined' ? null : navigator
  if (!nav) return {}
  return { languages: nav.languages, language: nav.language }
}

// 纯函数:保存的选择 > 浏览器语言列表 > 单个 language > en-US
export function detectLocale({ stored, languages, language } = {}) {
  const saved = String(stored ?? '').trim()
  if (saved) return normalizeLocale(saved)
  for (const tag of [...(Array.isArray(languages) ? languages : []), language]) {
    const text = String(tag ?? '').trim()
    if (!text) continue
    const resolved = normalizeLocale(text)
    if (resolved !== FALLBACK || text.toLowerCase().startsWith('en')) return resolved
  }
  return FALLBACK
}

最终 git diff --stat:2 个文件,+146 / −5(第一轮是 +168 / −8)。

4.8 我的独立复核

AI 的自证我不全信,所以把它的 diff 拿到一份干净的 Linux 检出上重新验证(Pi 是在 Windows 上跑的):

text 复制代码
LANG=C.UTF-8      i18n: pass 16 / fail 0
LANG=en_US.UTF-8  i18n: pass 16 / fail 0
LANG=zh_CN.UTF-8  i18n: pass 16 / fail 0
LANG=ja_JP.UTF-8  i18n: pass 16 / fail 0
全量: tests 347 / pass 347 / fail 0
build: ✓ built in 3.05s

再模拟真实浏览器加载模块:

text 复制代码
模拟中文浏览器首访 getLocale() = zh-CN | 设置 → 设置
手动选过英文的老用户 getLocale() = en-US
德语浏览器(不支持) getLocale() = en-US

两个平台、四种语言环境,结论一致。

4.9 提交 PR

按项目的 CONTRIBUTING.md,用 git commit -s 做 DCO 签名,PR 描述按官方模板填写,"实际跑过的检查"和"没跑的检查"分开写清楚。Pi 生成的 PR 描述我改成了英文(项目文档以英文为主),并补上了我的复核结果。

PR 地址:https://github.com/mdiri-ai/SoduWork/pull/24 (关联 Issue:https://github.com/mdiri-ai/SoduWork/issues/23)


五、另一个案例:AI 说"满足",不等于真的满足

为了确认放宽断言这件事不是偶然,我还做过一个独立任务:让 Pi 写一个零依赖的 CSV 解析命令行工具,验收标准全是工程指标(Decimal 精度、非法行跳过、退出码、兼容 Excel 带 BOM 的 CSV、≥10 个单元测试)。

它交出了约 280 行代码和 21 个全部通过的单测,还主动修好了中文 Windows 控制台 GBK 乱码的问题。

但我把代码拿出来写了几条刁钻用例,发现:

text 复制代码
警告:第 3 行:CSV 解析错误:field larger than field limit (1048576)
任务数:1        ← 本该是 3 条,后面的合法行被静默丢弃,退出码仍是 0

原因是 csv.Error 在 for 循环外层被捕获,一旦出错整个 reader 就废了;代码注释还写着"不中断整体流程"。它的自证表把这条标成了"满足"。

打回后,Pi 用 TDD 返修:先写回归测试,确认 11 个用例失败,再修代码,最后 35 个测试全绿,并主动说明了"行为变更"和"遗留限制"。

两次实测得出同一个结论:AI 很勤奋,但它的"满足"需要人来核实。


六、源码解读:这些约束是怎么落地的

6.1 Agent 指令模板:为什么 Pi 总会交"证据表"

client/main/pi-session/task-turn.js 一共 42 行,是整套机制的核心。它把需求拼成指令:需求 → 需求内容 → 验收标准 → 成果要求,有打回意见时再加一段(摘录):

js 复制代码
if (String(rework.feedback || '').trim()) lines.push('验收打回意见(' + actor +
  '没有通过上一版交付,以本条为最高优先)\n' + rework.feedback +
  '\n请先按这条意见返修现有交付,不要从零重做;完成后重新提交完整主成果和可复核证据。');

结尾固定一段要求:完成前逐条重读验收标准,每条都要留下可复核证据(命令、文件路径、运行结果或可观察行为) ,任何一条没满足就继续做,不得仅凭自述宣布完成,最后说明结果、验证情况和未解决的问题。

这就是 Pi 总是交"验收对照表"、总写"未解决的问题"的原因。但正如第四、五节所示,指令要求 AI 自证,并不能保证自证是真的,所以才需要下面这道门。

6.2 办结门:签收是算出来的,不是点出来的

hub-backend/app/services/delivery_truth.py 从任务、执行、成果、审批、部署记录里推导交付事实,刻意不建一张"最终状态表"。只要存在任何一个 blocker,就不能办结:

拦截码 含义
execution_failed / execution_unknown 执行失败,或没有明确的成功终态
artifact_missing / artifact_stale 没有固定成果版本,或成果来自旧会话
output_invalid 主成果没通过校验(日志、截图不能替代交付物)
delivery_pending 代码已提交,但还没交付到目标环境
review_pending / review_rejected 等待验收 / 验收被拒
approval_artifact_stale 批准的是旧版本,返修后必须重新审阅

源码注释的原意是:对早期版本的批准,不能授权返修之后的办结。

6.3 权限沙箱:Agent 能动你电脑的哪些东西

这部分在 client/main/access-control/,有几个设计我觉得很到位:

① 只读命令白名单(safe-query.js) :只有 ls / cat / head / tail / wc / rg / grep / pwd 加上有限的几个参数,并且路径在工作区内,才会自动放行。命令里带 &&、|、$、反引号、重定向的一律需要人工确认。这也解释了为什么 Pi 的核对命令每次都会弹审批:它们全是 && 串起来的。用 rg 搜索时还会强制排除 .ssh、.env、*.pem、id_rsa、credentials* 等敏感文件。

② 受保护路径(policy.js) :.git、.ssh、.aws、.kube、.env*、.npmrc、.netrc 以及各家 Agent 的配置目录一律受保护;AGENTS.md、CLAUDE.md、SKILL.md 这些指令文件禁止 Agent 写入 ,防止提示注入把恶意指令写进去长期留存。Windows 上路径里带 : 的(NTFS 备用数据流)也算受保护。

③ 授权指纹(broker.js) :"本会话允许"不是简单记住一个开关,而是对 {策略, 操作} 做稳定排序后算 SHA-256 作为授权键。文件操作按目标路径记(同一文件反复编辑不会重复弹窗),命令参数则严格匹配:命令内容一变,就要重新确认。审批 10 分钟没人处理会自动拒绝,同时挂起的审批最多 32 个;被拒绝过的操作,只有用户真正发起新一轮对话才能重新申请。

④ 三档权限:会话可以选"默认权限 / 完全访问 / 只读"。主菜那个会话处于"完全访问",所以 Pi 跑了一百多条命令都没有弹窗。这很高效,但对着真实仓库时,务必在需求里写清楚"不要 commit、不要 push"这类边界,我这次就是这么做的。

6.4 Harness 适配契约

client/main/agent-runtime/contract.js 定义了一组能力声明(会话恢复、取消、暂停、中途引导、审批响应、模型选择、MCP 等),每项都要标明是 native、emulated 还是 delegated。上层的需求、验收、插件只认能力,不认是哪家 Agent。在源码模式下,除了内置的 Pi,其他 8 个 Agent(Codex、OpenCode 等)还需要额外准备 Runner 的 Python 环境(hub-backend/.venv + requirements-runner.txt)。


七、总结

优点

  • 签收是硬约束:打回后需求真的停在"进行中",源码里有对应的 blocker。
  • 能在真实仓库里做真实工程:读贡献指南、跑基线、TDD、多语言环境复现、处理 CRLF,产出一个能直接提交的 PR。
  • 返修体验好:打回意见会自动带进下一轮,并要求"在现有交付上返修"。
  • 权限沙箱设计扎实:只读白名单、受保护路径、授权指纹、指令文件防篡改。
  • 启动器负责任:数据隔离、没有默认密码、只绑本机。

不足

  • 国内首次安装 Electron 会卡住,需要手动设置镜像;
  • 管理后台第一次打开是英文(本文的 PR 修的就是这个);
  • 执行中发的消息不会实时转向,而是排到下一轮;
  • 对个人用户来说偏重:Hub + 管理后台 + 桌面端三件套。

最重要的一点

这次测评里,AI 两次 在自证表里把实际没满足的条目标成了"满足":一次是静默丢数据,一次是放宽断言并编造理由。两次都是靠人的审查加上 SoduWork 的打回机制纠正过来的。

SoduWork 没办法让 AI 不犯错,但它把"AI 说做完了"和"真的做完了"之间的那道门,做成了流程,也写进了代码。


项目地址

如果你也被"AI 干得快,但没人敢认账"困扰过,欢迎去点个 ⭐ Star,或者 Fork 下来,像我一样让它修一个你自己发现的 Bug。

国内首次安装别忘了先执行 set ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/。

相关推荐
挖掘狂人11 分钟前
AI 把代码写得飞快,然后呢?聊聊我验收生成代码的一套流程
人工智能·程序员·ai编程
汤姆宝库12 分钟前
推理服务器的调度器:连续批处理与请求生命周期
人工智能
Rocky Ding*38 分钟前
【三年面试五年模拟】2026-11-05_月之暗面大模型算法岗一面、二面面经全解析
论文阅读·人工智能·深度学习·机器学习·aigc·强化学习·ai-native
Mirawork43 分钟前
Mirawork v0.32.0 更新体验:会话引用、任务分享与音频转写怎么用
人工智能·音视频
lucas_AI1 小时前
模型一升级缓存全作废?DeepSeek 实测:KV Cache 跨版本直接复用,首 token 快 5 倍
人工智能
在所不辞兄1 小时前
【人工智能驱动的电催化研究-05】条件、循环与数据检查
人工智能·深度学习·神经网络·机器学习·有限元·电化学·电催化
IT_陈寒1 小时前
JavaScript项目实战经验分享:从内存泄漏到性能优化的血泪史
前端·人工智能·后端
小oo呆3 小时前
【自然语言处理与大模型】如何编写优质的Skill?
人工智能
非优秀程序员11 小时前
第12章 烧钱无底洞!!!不烧就死,烧了也不知道啥时候死|《互联网产品恩仇录》
人工智能