Repository navigation
Windows: bili omp 覆盖层 symlink 全部 EPERM 被裸 catch{} 吞掉 → 每次启动都是全新未配置(即 #289 的 Windows 真因) #381
Description
症状
Windows 上每次 bili omp 启动的 oh-my-pi 都是全新未配置状态:无 config.yml、无凭据、无会话、无扩展——正是 #289 描述的症状(Linux 复现不出,因为根因是 Windows 特有的)。
根因:覆盖层符号链接全部 EPERM,被裸 catch {} 静默吞掉
bili omp 的隔离机制(src/launcher.ts runLaunch omp 分支,dist/index.js:62133-62140)是把 PI_CODING_AGENT_DIR 指向覆盖层 ~/.omp/agent-bili,并用 fs.symlinkSync 逐条目把真实目录 ~/.omp/agent 链接进覆盖层(refreshOverlayHome,HEAD launcher.ts:703-708,dist:61487-61496)。
问题链:
- Windows 上非特权进程创建 symlink 一律 EPERM(见下节"为什么与开发人员模式有关")
- 每次
symlinkSync失败被裸catch {}吞掉,refreshOverlayHome结尾仍无条件return true(launcher.ts:710)——无告警、无回退 - 于是覆盖层里只有 bili 自己生成的
models.yml+.bili-launch.pid,零个符号链接(实测Get-ChildItem -Force无一个 ReparsePoint) - omp 忠实地从
PI_CODING_AGENT_DIR解析一切(config.yml / agent.db / sessions/ / extensions/ 全在该目录下),拿到空目录 → 每次都是全新实例。plainomp完全正常(真实~/.omp/agent/config.yml里setupVersion: 2健在),只有经 bili 启动才受害
上游 HEAD(550da62)仍有此问题:launcher.ts 最近 10 次提交无一涉及 Windows/symlink/EPERM。
为什么与开发人员模式有关
Windows 的符号链接创建需要 token 持有 SeCreateSymbolicLinkPrivilege。普通用户账号只有开启「开发人员模式」(或提权/内置管理员)才持有该权限——这与 git-for-windows/WSL 文档里"无特权 symlink"是同一约束,不是 bili 配错了环境。三种链接方式在受害机器上的实测(bili 用的正是第一种):
fs.symlinkSync(file) → EPERM: operation not permitted
fs.symlinkSync(dir) → EPERM: operation not permitted
junction (mklink /J) → OK ← 无需任何特权
这台机器上:非提权 token、whoami /priv 无 SeCreateSymbolicLinkPrivilege、注册表 AppModelUnlock 键不存在。开了开发人员模式后症状即消失——但默认安装 bili 的用户不应该需要知道这个前提,而且失败被静默吞掉意味着用户永远无从诊断。
次生危害:merge-back 对 SQLite 三件套非原子改名,且竞态活库
用户在覆盖层会话里写出的 agent.db* / history.db / models.db,会在下一次启动被 mergeOverlayEntry(launcher.ts:713-793)按 mtime 逐个独立 rename 回真实目录,输家改名 *.bili-conflict。实测观察到:
- db / -wal / -shm 三件套分头搬迁,可能不同代次混拼(SQLite 损坏风险)
- 真实目录里躺着
agent.db.bili-conflict(299KB)、models.db.bili-conflict(1.4MB)、config.yml.bili-conflict等坟场 - 14:23:48-14:23:50 一轮 merge 发生时,普通 omp 会话正开着这些库(16252/18188 存活)——改名进行中的 SQLite 文件
- 覆盖层会话因此从不加载扩展(
agent-bili无 config.yml),bili 的压缩注入对bili omp会话实际全程失效
环境
- billion-context 0.1.66(npm 全局,dist 内无 "junction" 字样,无任何回退)
- Windows 11 Enterprise 29648,非提权,开发人员模式关,Node v26.7.0
- oh-my-pi(omp.exe,AppData\Local\omp)——omp 侧已排除:ACP/TUI 与 bili 共用同一个
getAgentDir()解析,忠实读PI_CODING_AGENT_DIR,无独立故障
建议修复
- win32 回退链:
symlinkSync抛 EPERM/EINVAL 时,目录条目退fs.symlinkSync(target, link, 'junction')(已实测无需特权即可用),文件条目退linkSync硬链接(同卷)→ copy - 失败要响:全部链接失败时不要
return true——至少 warn 一行"overlay 空心化,omp 将以空配置启动(Windows 需开发人员模式或改用 junction 回退)",或直接中止 - SQLite 三件套整体搬迁:merge 时把
x/x-wal/x-shm作为一组处理;真实文件被打开持锁时(rename 失败)跳过本轮 merge 而非半搬 - 可选:检测到
SeCreateSymbolicLinkPrivilege缺失时在bili start阶段就提示,而不是等到启动客户端时静默劣化
最小复现
任何未开开发人员模式的 Windows 10/11:
npm i -g billion-context
bili omp # → 进入的是全新未配置 omp
Get-ChildItem -Force $env:USERPROFILE\.omp\agent-bili |
Select Name, LinkType # → LinkType 全空,只有 models.yml + .bili-launch.pid
node -e "require('fs').symlinkSync(process.env.USERPROFILE+'\\.omp\\agent\\config.yml', require('path').join(require('os').tmpdir(),'l1'))" # → EPERM开启开发人员模式 + 注销重登后,同一条 bili omp 的覆盖层即正常挂链,症状消失。
补充说明:此 issue 由机器上的完整取证(进程/日志/session 文件对账)与上游源码逐行核对得出;与 #289(同症状、Linux 无法复现)、#367(persist 目录 rename 被 AV 锁,另一类 EPERM)均不重复。
Activity
ranxianglei commentedon Aug 31, 2026
ework 接单
ranxianglei commentedon Aug 31, 2026
[bot] 🏷 已修复,PR: #382
根因:
refreshOverlayHome(src/launcher.ts)里每个fs.symlinkSync都被裸catch {}吞掉,且函数无条件return true。Windows 非特权进程没有SeCreateSymbolicLinkPrivilege,symlink 全部 EPERM → 覆盖层只剩 models.yml + pid 标记、零链接 → omp 读到空目录 → 每次全新未配置(即 #289 的 Windows 真因)。修复(src/launcher.ts):
linkOverlayEntry— symlinkSync 抛 EPERM/EACCES/EINVAL 时按条目类型降级:目录 →symlink 'junction'(无需特权),文件 → 硬链接(同卷)→ 拷贝。无需开启开发者模式。refreshOverlayHome— 逐条统计链接成功/失败。覆盖层全空(无任何条目可解析)时打一条醒目警告并return false,让 launcher 中止而不是把客户端指向空目录;部分失败也报出来。顺带修了一个EEXISTbug(per-entry 循环已正确链接、或 SQLite 回滚残留的条目若被重复链接会误报为失败)。mergeSqliteSet— SQLite 三件套(.db/.db-wal/.db-shm)作为整体迁移 + 两阶段回滚,锁住/半迁移的集合不会再损坏真实 home 的 db。nlink>1且dev+ino与真实文件相同)刷新时重新指向,绝不合并回去(防数据丢失)。测试(tests/launcher.test.ts,+6):在 Linux 上通过禁止非 junction 的
fs.symlinkSync来模拟 Windows EPERM——目录→junction、文件→硬链接、文件→拷贝、全失败→空层→undefined、SQLite 整体迁移、SQLite 回滚、写穿硬链接重指向。前置检查:
typecheck✅ /test868 通过 0 失败 ✅ /build✅注意:本环境无法跑真实 Windows,降级路径靠模拟 EPERM/EACCES 的单测覆盖;建议发布前在真实 Windows 上对
bili omp做一次冒烟。ranxianglei commentedon Aug 31, 2026
合并了 ,提交一个新版本pr
ranxianglei commentedon Aug 31, 2026
[bot] 🏷 已提交新版本 release PR: #383(v0.1.67)
2026-08-31_release-v0.1.67,仅改package.json的version(0.1.66→0.1.67),独立 release commitrelease v0.1.67。typecheck✅ /test868 通过 0 失败 ✅ /build✅npm publish+ 打 tagv0.1.67+ 创建 GitHub Release。本次发布内容即 #382(Windows 覆盖层 symlink EPERM 降级 + 空层显式失败 + SQLite 三件套整体迁移)。合并请由你操作。
wosnxn123 commentedon Aug 31, 2026
对已合并修复 PR #382 的复审意见(基于 issue 正文同一台受害机器的实证)
先感谢:四条建议全部落实,PR #382 已合并,junction/hardlink/copy 回退链 + fail-loudly + SQLite 集合合并的方向完全正确。以下是复审
src/launcher.ts(PR head497c131)时发现的 2 个残余边缘缺陷与 1 个待验证点,按可自动修复的格式给出。缺陷 1:mergeSqliteSet 的成员级 mtime 裁决仍可能拆散 WAL 与主库(违背本函数自身的存在目的)
mergeSqliteSet注释声明"A WAL is only valid against its exact main db, so the set must move as a unit",但实现是每个成员独立比较 mtime、新者胜。可构造出跨代次混拼:构造场景(覆盖层会话未 checkpoint 时,真实 home 里同一库被并行更新):
real/agent.dbmtime = T2(裸 omp 会话刚写过)overlay/agent.dbmtime = T1(T1 < T2)overlay/agent.db-walmtime = T3(T3 > real 侧 wal,覆盖层会话写入的 WAL 帧)逐成员裁决结果:
db真实侧胜(overlay 版改名.bili-conflict留在真实目录)→wal覆盖层侧胜(搬入真实目录,替换真实 wal)。最终真实 home = T2 主库 + 覆盖层会话的 wal——两者 salt/页号互不匹配。SQLite 恢复时会校验 salt 并丢弃错配 WAL,即覆盖层会话的全部未 checkpoint 写入静默丢失;而"输家"overlay db 被改名成冲突文件躺在真实目录,进一步诱导用户以为已合并。建议修复:按主库(base
x.db)的 mtime 裁决一次胜方;胜方的整组(x、x-wal、x-shm,各自存在才动)一起移动,败方成员统一改名.bili-conflict。成员级比较可以完全去掉。(注:主库不存在、仅 sidecar 存在的孤儿集合应整体作为陈旧残渣处理——直接丢弃或统一入
.bili-conflict,不要单独搬进真实目录。)验收标准:
x.db与x.db-wal必须来自同一侧(同代次).bili-conflict后缀,不出现跨侧混拼缺陷 2:sqliteSetMembers 不覆盖 -journal;既有冲突文件被静默覆盖
a)
sqliteSetMembers只列[base, base-wal, base-shm]。回滚日志模式下 SQLite 会生成base-journal,它仍走逐条目 merge 老路,与主库可以拆开移动。建议:journal加入集合成员(或至少在集合检测的 "some sidecar present" 判定里考虑它)。b)
mergeSqliteSet与mergeOverlayEntry的失败者处理都是renameSync(src, dst.bili-conflict);renameSync在 Windows/POSIX 都会静默覆盖已存在的同名目标。跨启动重试(缺陷链:上一轮 merge 失败留在 overlay → 本轮又失败)时,上一轮的败方数据无声消失。建议:目标已存在时加时间戳后缀(如.bili-conflict-<n>或.1),或在覆盖前至少 warn 一行。待验证点:Windows 真机上首选分支创建的是 file-tag 符号链接(目录条目)
linkOverlayEntry首选fs.symlinkSync(target, link)不带 type 参数;libuv 在 Windows 上 type 省略时按 'file' 创建 reparse point,对目录目标即产生 file-tag 符号链接。多数 Win32 API 能跟随它,但readdir(withFileTypes)、部分备份/索引工具的行为不保证,且这正是 PR 描述里自述"未能在真 Windows 冒烟"的路径。建议在 win32 分支显式化:先
lstatSync(target),目录直接symlinkSync(target, link, 'junction')(免特权、语义无歧义),文件用'file';这样也省掉首试失败再回退的一次 syscall。真机验证数据点(本 issue 受害机,Win11 Enterprise 29648,开发人员模式开):可对照Get-ChildItem $env:USERPROFILE\.omp\agent-bili | Select Name, LinkType, Target确认 sessions/、extensions/ 等目录条目是否以 LinkType=SymbolicLink 出现且内容可正常枚举——若是,此点降级为代码清晰度建议;若有异常,junction 优先方案是正解。环境注:以上分析基于 issue 正文中的同一台机器(Windows 11、Node v26.7.0、billion-context 0.1.66)与 PR #382 diff 逐行复审;复现数据(agent.db.bili-conflict 299KB / models.db.bili-conflict 1.4MB / WAL 三件套分头搬迁的时间戳对账)见本 issue 取证记录。
ranxianglei commentedon Aug 31, 2026
ework继续跟进
ranxianglei commentedon Aug 31, 2026
[bot] 🏷 已按 review 意见修复,PR: #396
针对 FLOOR 10 的 review,4 处修改(
src/launcher.ts+ 3 个新测试):1.
mergeSqliteSet按主库整体裁决(Defect 1)原逐成员 mtime 裁决可能把"较新的主库 + 另一侧较新的 WAL"拼在一起(salt/page 不匹配 → SQLite 丢弃 WAL → overlay 会话未 checkpoint 的写入丢失)。现改为:以主库 mtime 一次性裁决,赢家整组迁移,输家成员全部保留为
.bili-conflict。孤儿集合(无主库、只有 sidecar)视为陈旧残留,统一丢弃,绝不逐个迁移。2.
sqliteSetMembers补上-journal(Defect 2a)rollback-journal 模式的
x.db-journal之前不在集合里,会被单独合并(同样的跨侧风险)。3. 冲突重命名不覆盖(Defect 2b)
renameSync(src, dst.bili-conflict)会静默覆盖同名目标,重试轮次会冲掉上一轮保留的输家。mergeSqliteSet与mergeOverlayEntry现都用freeConflictName(.bili-conflict、.1、.2…)。4.
linkOverlayEntry显式 symlink 类型(验证点)首次尝试用无类型的
fs.symlinkSync(target, link),在 Windows 上对目录目标会创建 file 标记的重解析点(libuv 无类型默认'file'),readdir(withFileTypes)/部分备份索引器可能不跟随。现显式:win32 目录 →'junction'(无需权限、无歧义),文件 →'file'。测试:新增 3 个(主库裁决不拼接、
-journal成组、冲突重命名不覆盖)+ 原有 6 个 #381 测试;871 通过,typecheck + build 绿。发布顺序:建议在 v0.1.67 release PR(#383)合并之前合并本 PR,让 release 带上这些修复;若 v0.1.67 已发布,可作 v0.1.68。
fix: ranxianglei#381 review — SQLite set adjudication, -journal, conf…
Merge pull request ranxianglei#396 from ranxianglei/2026-08-31_window…
feat: codex shared-state mode — keep the SQLite state in the real hom…
feat: codex shared-state mode — keep the SQLite state in the real hom…