ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

oh-my-openagent Windows CI 根因分析:以 Red-Green 证据链修复并发构建竞态与调度器饥饿

2026/9/19 22:28:04 拓冰建站 浏览量
oh-my-openagent Windows CI 根因分析:以 Red-Green 证据链修复并发构建竞态与调度器饥饿 oh-my-openagent Windows CI 根因分析以 Red-Green 证据链修复并发构建竞态与调度器饥饿【免费下载链接】oh-my-openagentOmO: Just type mass ulw keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent本篇指南完整复盘 oh-my-openagent 仓库一次针对fix/windows-ci-root-causes分支的 Windows CI 根因修复过程从真实windows-latest矩阵失败出发沿 failing-first先红→ 根因定位 → 修复 → GREEN后绿→ 全量回归门禁的证据链逐一拆解三类 Windows 独有问题——共享 plugin 树的并发npm ci竞态、可再生 admission lease 的调度器饥饿、以及 memory/memfs 相关的平台基线失败。读者将掌握一套可复用的方法论如何把平台偶发失败转化为确定性的虚拟时间测试、如何用构建图依赖不变量杜绝并发写入、以及如何用 CI-pinned 工具链在本机复现完整回归门禁。一、背景Windows 矩阵上的故障形态与证据组织方式oh-my-openagent 是一个以 Bun 为运行时、包含大量跨进程能力Codex 插件、Senpi 任务生命周期、memory MCP、memfs 同步的大型仓库其 CI 在ubuntu-latest、macos-latest、windows-latest三个平台上运行完整根测试套件约 1.4 万条测试、1800 个文件。当windows-latest频繁失败而其他平台全绿时问题往往不在业务逻辑本身而在平台差异调度器时序、文件句柄语义、路径分隔符、npm 树并发写入。该分支的全部调查过程被记录在 .omo/evidence/20260810-windows-ci-root-causes/ 目录中文件命名即方法论本身red-*记录失败优先failing-first的复现证据green-*记录修复后的确定性证明full-suite.md记录最终全量回归门禁的归因结论。这种先红后绿的目录结构保证每一次修复都有对应的失败快照可以追溯。二、根因一共享 plugin 树上的并发npm ci竞态2.1 RED干净检出下的安装竞态red-install.md记录了最直观的失败在真实的windows-latestjob 中从干净检出执行bun install --frozen-lockfile安装阶段直接崩溃npm error code ENOTEMPTY npm error syscall rmdir npm error path ...\packages\omo-codex\plugin\node_modules\undici-types npm --prefix packages/omo-codex/plugin ci failed error: script build:senpi-plugin:stage exited with code 1同树的另一次 post-merge 运行则在postcss\lib目录下失败日志中还伴随TAR_ENTRY_ERROR与ENOENT。关键事实是失败发生在任何测试运行之前——npm ci正在删除/解压packages/omo-codex/plugin/node_modules时另一个进程同时也在操作同一目录树。2.2 根因定位构建图缺少一条依赖边根因指向根构建脚本 script/build.ts。该脚本把整个仓库构建拆成一组BuildNode按依赖图并发调度type BuildNode { id: string; command: string; args: string[]; deps: string[]; };修复前的关键节点定义存在缺口{ id: codex-plugin, command: bun, args: [run, build:codex-plugin], deps: [git-bash-mcp, lsp-tools-mcp, lsp-daemon] }, { id: senpi-plugin, command: bun, args: [run, build:senpi-plugin:stage], deps: [ast-grep-mcp, lsp-daemon] },codex-plugin与senpi-plugin之间没有依赖关系两个节点会被并发调度。而 script/build.ts 的注释明确说明senpi-plugin会 stage Codex 的 ulw-loop 组件并可能在packages/omo-codex/plugin工作区中运行npm cicodex-plugin自身的构建也会对该树运行npm ci。两个安装器同时清理/重建同一个node_modules在 POSIX 上通常侥幸通过在 Windows 上则因文件句柄与目录锁定语义暴露为ENOTEMPTY/TAR_ENTRY_ERROR。2.3 GREEN用 AST 不变量测试钉死构建图先红后绿的第一步是让失败可被测试捕获。red-build-graph.md记录了最初的 RED 断言一个解析script/build.ts中BuildNode[]的测试期望senpi-plugin的依赖包含codex-plugin实际却只得到[ast-grep-mcp, lsp-daemon]。经过两次 harness 层面的探路TypeScript 7 不再暴露 legacy compiler API 与forEachChild最终 RED 使用仓库原生 async AST API只失败在缺失的图依赖上。修复后新增的 script/build-dependency-graph.test.ts 用正则捕获build.ts源码中的节点定义并断言依赖关系test(#given Codex and Senpi share plugin dependencies #when scheduled #then Codex installation completes first, () { const senpiNode buildSource.match(/\{ id: senpi-plugin.*deps: \[([^\]]*)\] \}/) expect(senpiNode?.[1]).toContain(codex-plugin) })对应的构建图改为{ id: senpi-plugin, command: bun, args: [run, build:senpi-plugin:stage], deps: [ast-grep-mcp, lsp-daemon, codex-plugin] },green-build-graph.md显示该测试从 RED0 pass / 1 fail转为 GREEN1 pass / 0 fail证明Codex 先完成对共享 plugin 树的安装Senpi 再开始 stage这一不变量被程序化钉死。green-build-graph-ordering.md进一步在真实构建上验证了节点完成顺序... build:lsp-daemon, build:lsp-tools-mcp, build:codex-plugin, build:senpi-plugin build: all steps completed ROOT_BUILD_DONE exit0build:codex-plugin的完成序先于build:senpi-plugin并程序化断言 codex 索引 senpi 索引。该文档还对构建中出现的UnknownLockfileVersion: failed to parse lockfile: bun.lock做了归因这是工作站本机 Bun 1.4.0 写入的未跟踪组件 lockfile 被 CI-pinned Bun 1.3.12 读取所致子构建忽略后重新解析并成功且根bun.lock经git diff --exit-code验证未变CI 每个 job 只用单一 Bun 版本该警告不会在 CI 出现。2.4 为什么并行的清理 PR 不被接受为根修复competing-pr-6708.md评估了一个并发提交的修复方案在npm ci之前先rm -rf packages/omo-codex/plugin/node_modules。结论是它不构成根修复——因为构建图仍然并发调度两个节点删除操作本身就在和另一个进程的删除/解压竞争只是把失败窗口换了位置。正确的根修复是建立单一拥有者让codex-plugin先完成senpi-plugin依赖它。这体现了证据目录里反复强调的纪律减少失败数量不等于绿色结果只有消除根因才算修复。三、根因二可再生 admission lease 的调度器饥饿3.1 RED真实时钟下的竞争窗口red-admission-lease.md记录了acquireSessionAdmissionLease在真实windows-latest上的失败expect(waiter.kind).toBe(contended) Expected: contended Received: acquired (fail) acquireSessionAdmissionLease #given a holder that keeps renewing #when a waiter contends past several stale windows #then the holder is NOT reclaimed and the waiter yields contended [437.00ms]语义应当是持有者持续续期则等待者必须让步contended但 Windows runner 却把一个仍存活的持有者回收了。该测试依赖 40 ms 的续期 tick、120 ms 的 stale 阈值和 300 ms 的真实等待运行在 1.4 万条测试的进程中——调度器饥饿让壁钟时间变得不确定一次延迟的续期就让等待者误判持有者已死。3.2 底层实现token 栅栏 CAS 接管要理解修复方向先看 packages/senpi-task/src/lifecycle/admission-lease.ts 的实现。这是一个可续期的 owner-token 租约区别于withTaskRecordLock的 5 秒 mtime 回收——那对长生命周期的批量准入段太激进租约文件位于stateDir/locks/session-parentSessionId.lock体为{pid, token, renewed_at}token每次获取时由randomBytes(16).toString(hex)铸造是栅栏令牌持有者在定时器上刷新renewed_at默认renewMs为 1000 msstaleMs默认等于 3 个刷新间隔防止单次卡顿的续期 tick 误伤活持有者接管是 compare-and-swap等待者先观察磁盘上的 token 与时间戳在短互斥下同时复验 token 和 stale 状态后才写入自己的 token——持有者在观察与 CAS 之间完成续期时永远不会被顶替释放也是 CAS只有当磁盘 token 仍属于自己时才删除租约文件被顶替者晚到的释放不会误删继任者。3.3 GREEN把竞争迁移到虚拟时间green-admission-lease.md展示了修复后的确定性测试用jest.useFakeTimers()驱动真实续期回调、真实文件系统租约写入、真实记录互斥锁、真实 token 新鲜度检查与真实竞争路径但把时间推进交给虚拟时钟bun test packages/senpi-task/src/lifecycle/admission-lease.test.ts -t holder renewing on virtual time(pass) acquireSessionAdmissionLease #given a holder renewing on virtual time #when several stale windows pass before a waiter contends #then the holder is NOT reclaimed and the waiter yields contended [5.99ms]在 packages/senpi-task/src/lifecycle/admission-lease.test.ts 中虚拟时间场景的写法是真实持有者每 40 ms 续期、stale 阈值 120 msjest.advanceTimersByTime(400)推进十个续期 tick 后再发起竞争断言waiter.kind为contended且holder.isOwner()仍为truefinally中恢复真实时钟。另一个racing waiters场景一个 stale 租约、两个等待者同时发起接管 CAS把双方的续期与有限等待全部置于虚拟时间renewMs: 50、staleMs: 150、acquireTimeoutMs: 1_500于是谁赢只由接管 CAS 决定调度器暂停再也无法让输家顶替活着的赢家。全文件 4 pass / 0 fail19 个 expect覆盖基础获取/释放、虚拟时间活续期、崩溃持有者接管、双等待者 CAS 栅栏四类场景。3.4 突变证明断掉续期必须失败证据目录提供了测试真的在测试的突变证明red-green-windows-remaining.md临时把赢家的renewMs从 50 改成 60_000模拟续期停止永不提交exactly one wins断言立刻失败期望长度 1、实际 2EXIT1还原后恢复 GREEN。这证明断言仍然精确针对它命名的回归而不是被修成了永远通过的摆设。四、根因三Windows 平台基线失败的三类具体缺陷red-green-windows-remaining.md基于dev分支最新运行run 31375080844jobtest (windows-latest)的精确三条失败逐一定位了三个独立的 Windows 缺陷4.1 memory MCP 服务器未释放的文件句柄 默认超时(fail) omo-memory MCP server #given a fresh project #when create then str_replace run through tools/call #then the memory repo records them [5016.00ms] ^ this test timed out after 5000ms.伴随签名EBUSY: resource busy or locked, rm C:\Users\RUNNER~1\...\omo-memory-mcp-f8CYoG表明Windows 在递归清理临时目录时仍持有 git 的句柄afterEach抛EBUSY而驱动真实 git 子进程的工作被 5 秒默认超时截断。修复遵循仓库既有约定重试式 unlinkmaxRetries/retryDelay加setDefaultTimeout(win32 ? 30_000 : 5_000)的平台分档超时。该约定在仓库中多处落地例如 packages/omo-senpi/src/components/memory/commands/memfs.test.ts 的setDefaultTimeout(process.platform win32 ? 30_000 : 5_000)。4.2 /memfs sync镜像 remote 被解析成.(fail) /memfs sync #given a reachable mirror #when sync runs #then the push is reported as successful [5015.00ms] Received: push to C:\Users\RUNNER~1\...\memory-mirror-TWQI6I failed: remote: fatal: not a git repository: . ! [remote rejected] main - main (missing necessary objects)根因是镜像被配置成了原始 Windows 路径反斜杠、8.3 短名RUNNER~1git 把 remote 解析成了.。修复方式是配置规范的、斜杠分隔的file://URL在 packages/omo-senpi/src/components/memory/commands/memfs.test.ts 中可见先用realpathSync.native展开临时目录顺带消除RUNNER~1短名再通过repo.configSet(CONFIG_KEY, \file://${bare.replaceAll(\, /)})写入 URL。这与已通过的memory-corehooks mirror 测试保持一致且本地通过同时证明生产推送路径接受file:// 镜像 URL。4.3 racing waitersstale 窗口远短于轮询等待同一 job 中acquireSessionAdmissionLease的racing waiters失败406 ms暴露了参数配比缺陷等待者的 stale 窗口150 ms远短于败者持续轮询的 1500 ms一次延迟续期就能让败者顶替活着的赢家。修复即第三节所述的虚拟时间化——由接管 CAS 单独决定所有权真实续期、真实锁、真实 CAS 与所有权断言全部不变无任何 mock、跳过、重试或弱化。4.4 GREEN 汇总三处修复后的本地macOS证据packages/senpi-task/src/lifecycle/admission-lease.test.ts 4 pass 0 fail packages/omo-senpi/src/mcp/memory-server.test.ts 4 pass 0 fail packages/omo-senpi/src/components/memory/commands/memfs.test.ts 15 pass 0 fail每一项修复都移除了真实的 Windows 特异性缺陷调度器决定所有权、未释放文件句柄、被破坏的 remote URL而不是掩盖它。五、全量回归门禁14125 通过、3 失败及其归因方法5.1 门禁命令与观测full-suite.md记录了最终全量根测试门禁的本地复现方式CI-pinned Bun 1.3.12cd worktree npx --yes bun1.3.12 test观测结果14125 pass 11 skip 3 fail 1 snapshots, 78836 expect() calls Ran 14139 tests across 1834 files. [271.32s] ROOT_TEST_DONE exit1三个失败分别为(fail) Senpi compatibility test script #given published root package #when payload contract is inspected #then senpi payload is contained while local build stays available (fail) #given the generated Codex installer #when release versions are synchronized #then its embedded package version matches the root release version (fail) omo-senpi ulw-loop runtime #given OMO_BIN is set #when resolving the default omo binary #then Bun is not needed and PATH is ignored5.2 归因一环境变量泄漏非本分支所致第一个失败是环境泄漏本工作站导出OMO_AGENT_TOOLKIT_BINagent harness 自身会设置它而解析器优先使用 toolkit 二进制而非OMO_BIN测试未清除该变量于是读到了宿主机值Expected: /custom/omo Received: /Users/yeongyu/.bun/install/global/node_modules/omo-ai/bin/omo-agent-toolkit.js本分支触及的文件无法影响该解析器属于环境性泄漏与代码改动无关。5.3 归因二与三生成工件滞后于版本号后两个失败都检查生成产物打包后的 Codex 安装器内嵌版本、发布 payload 契约。CI 在bun install的prepare→bun run build阶段会重新生成它们而本 worktree 持有的是提交入库的旧产物滞后于v5.0.0-beta.5版本号提升。这同样是环境/快照差异而非代码回归。5.4 决定性交叉验证在同一棵树上的决定性交叉验证来自dev分支 run 31375080844 的矩阵结果test (ubuntu-latest) success test (macos-latest) success test (windows-latest) failure三个失败在干净 CI 检出上全部通过因此它们不属于本 PR 修复的 Windows 基线本分支也没有使它们回归。同时本分支触及的每个测试全绿admission lease 4/4、memory MCP 4/4、memfs 15/15、build graph 1/1、senpi-task lifecycle 103/103全量套件相对基线的差值为零——所有非绿色用例都可在同一本地环境的未修改基线上复现。5.5 失败基线审计red-windows-tests.md还修正了此前对失败数量的误报12 到 5的说法不被日志支持dev参考运行 14039 pass / 15 fail、PR #6695 为 14069 pass / 8 fail、PR #6705 为 14070 pass / 7 fail其中既包含稳定的平台不兼容子集也混有独立的偶发失败如 admission lease 竞争、memory MCP 记录且PR #6700持有 5 个稳定失败。这提醒读者跨平台矩阵的失败计数必须从原始 job 日志核对不能只读摘要。六、工具链可复现性CI-pinned typecheck 与 build 门禁typecheck.md演示了在本机精确复现 CI的方法先npx --yes bun1.3.12 install --frozen-lockfile --ignore-scripts再npx --yes bun1.3.12 run typecheck。初跑时本机 Bun 1.4.0 / Node 26 在未改动代码上失败缺失 workspace 类型条目、Senpi factory 强转不匹配而同一 commit 的 GitHub typecheck 在三个平台全绿——被判定为环境漂移而非补丁问题。使用 CI-pinned 工具链后bun install v1.3.12 (700fc117) $ tsgo --noEmit bun run typecheck:script bun run typecheck:packages PINNED_TYPECHECK_DONE exit0版本为 Bun 1.3.12 与 tsgo 7.0.0-dev.20260518.1与 CItypecheck矩阵完全一致覆盖根、script 与全部 package 的 TypeScript 工程不改源码、不抑制诊断。green-build-graph-ordering.md中 CI-pinnedbun run build同样以ROOT_BUILD_DONE exit0通过。七、纪律与边界成功标准、范围控制与残留风险self-review.md以四个成功标准自评干净的 Windows 依赖/安装路径——已由 RED 安装竞态、AST 不变量测试RED→GREEN与真实构建顺序证明可再生 admission lease 的调度器安全——由虚拟时间测试与突变探针证明真实续期/锁/原子写/所有权断言均未弱化完整的跨平台回归门禁——本地门禁全绿LSP 诊断 0 错误、聚焦套件、CI-pinned installtypecheckbuildPR 自身的test (windows-latest)job 是最终判定证据持久化、不可绕过的指导——AGENTS.md增加 ANTI-PATTERNS 条目与三条 PR MERGE POLICY 规则明确无--admin、无 required-check 覆盖、基线上红即为缺陷、减少失败数量不等于绿色结果等行为要求。范围控制并发 PR #6708 的rm -rf前置清理虽已合入dev但它处理的是npm 在 Windows 上无法清空被锁目录这一不同失败模式本 PR 不移除它避免无关范围扩散。残留风险Windows 行为无法在 macOS 工作站直接执行file://镜像 URL 与两处重试式 unlink 仅通过本地证明 对齐已通过的memory-coremirror 测试间接验证最终由 PR 的真实test (windows-latest)job 裁决。八、方法论总结先红后绿证据留痕每个根因先有真实平台的 RED 快照再有修复后的 GREEN 证明最终以全量套件的分支触及全绿 剩余失败可在基线上复现完成归因全程不虚构、不绕过。把偶发转化为确定对依赖真实时钟的竞争测试用虚拟时间把谁赢的决定权交还给 CAS 等确定性原语并用突变探针证明断言仍能捕获原始回归。并发写入必须建立单一拥有者共享目录树的并发安装不能靠删了再装缓解而要在构建图上显式声明依赖并用解析构建图源码的 AST 测试钉死不变量。平台差异要逐个落到根因EBUSY、.远程、调度器饥饿各有独立修复而非统一加大超时了事——超时放宽仅按仓库既有约定应用于真实 git 驱动的测试。聚焦验证套件focused-suites.md为上述结论提供了可随时重跑的最小复现集bun test script/build-graph-dependencies.test.ts1 pass、bun test packages/senpi-task/src/lifecycle103 pass420 个 expect覆盖准入租约、批量准入、reconciliation、关闭、重挂载与跨进程所有权、bun test packages/omo-senpi/plugin/scripts/stage-agent-toolkit.test.mjs3 pass且全部进程以退出码 0 结束、无残留服务或子进程。【免费下载链接】oh-my-openagentOmO: Just type mass ulw keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考