首页
/ oh-my-openagent: 让 boulder.json 的 worktree_path 不再让 atlas Hook 崩溃——一次运行时类型防御修复的完整拆解

oh-my-openagent: 让 boulder.json 的 worktree_path 不再让 atlas Hook 崩溃——一次运行时类型防御修复的完整拆解

2026-09-04 11:53:18作者:房伟宁

本文基于 oh-my-openagent 仓库中一份真实的 PR 描述(.agents/skills/work-with-pr-workspace/iteration-1/eval-2/with_skill/outputs/pr-description.md),完整还原 fix(atlas): prevent crash when boulder.json missing worktree_path 这个问题的根因、传播链与修复方案:readBoulderState()as BoulderState 绕过类型系统后,worktree_path: null 如何沿 atlas 的 idle-continuation 链路传播,以及如何通过"读取边界净化 + 使用点守卫 + 测试覆盖"三层手段把运行时类型违约挡在门外。读完后你将掌握一套可复用的 JSON 状态文件运行时防御模式,并能直接对照仓库源码验证每个结论。

背景:boulder 状态机与 boulder.json 读取路径

状态模型:works 映射加根级镜像

oh-my-openagent 用一个纯 JSON 状态机跨会话追踪活跃工作计划(即 "boulder")。状态持久化在 <worktree-root>/.omo/boulder.jsonschema_version: 2),由独立包 @oh-my-opencode/boulder-state 承载,零 npm 依赖(见 packages/boulder-state/AGENTS.md)。

核心接口定义在 packages/boulder-state/src/types.ts

export interface BoulderState {
  schema_version?: 2
  active_work_id?: string
  works?: Record<string, BoulderWorkState>
  active_plan: string
  started_at: string
  ended_at?: string
  elapsed_ms?: number
  status?: BoulderWorkStatus
  updated_at?: string
  session_ids: string[]
  session_origins?: Record<string, "direct" | "appended">
  plan_name: string
  agent?: string
  worktree_path?: string   // ← 本次修复的主角
  task_sessions?: Record<string, TaskSessionState>
}

worktree_path 的类型是 string | undefined:字段可以不存在,但不能是 null。状态机还有一个关键机制:每个 BoulderState 携带 active_work_id + 一个 works 映射,根级字段(active_planplan_namesession_idsworktree_path 等)是当前活跃 work 的镜像——selectMirrorWork() 选中活跃 work,projectWorkToMirror() 把它拷贝到根级。镜像投影的实现见 packages/boulder-state/src/storage/shared.tsstate.worktree_path = work.worktree_path 一行直接把 work 上的值原样复制到根级)。这意味着:任何一个 work 的 worktree_pathnull,镜像投影就会让根级也变成 null——脏值不会只停在原地。

读取路径:as 断言绕过了 TypeScript

readBoulderState() 的规范实现位于 packages/boulder-state/src/storage/read-state.ts,omo-opencode 插件侧的 packages/omo-opencode/src/features/boulder-state/storage.ts 已改为对 @oh-my-opencode/boulder-state 的 re-export 垫片(核心逻辑已抽取到共享包)。其读取流程是:

  1. existsSync(filePath) 检查文件是否存在,不存在返回 null
  2. readFileSync + JSON.parse(content) 得到原始值 parsed
  3. 拒绝非对象、数组、空对象 {} 载荷(Object.keys(parsed).length === 0 时返回 null);
  4. 执行 normalizeState(parsed) 做规范化;
  5. 最终 const state = parsed as BoulderState 完成断言并返回。

第 5 步就是 PR 所指出的问题源头:return parsed as BoulderState 在运行时完全不做类型检查,TypeScript 只在编译期起作用。任何能通过前 4 步的 JSON 内容,都会以 BoulderState 的身份流遍下游。

值得一提的是,normalizeState()read-state.ts#L35-L61)其实已经体现了一套"读取边界净化"的既有范式——session_ids 缺失或非数组时默认为 []task_sessions 缺失或非对象时默认为 {}、单会话缺失 session_origins 时回填 "direct"这些净化都是 typeof/Array.isArray 判断驱动的。本 PR 做的事,本质上就是把同一套范式补到 worktree_path 这个字段上。

问题拆解:worktree_path 的两种缺法,只有一种被正确对待

undefinednull 是两回事

  • 字段缺失boulder.json 里没有 worktree_path):JSON.parse 后取该属性得到 undefined,与类型 string | undefined 相符,下游全部按"可选"正常处理。这类状态很常见——worktree 支持加入之前创建的 boulder、或创建时未带 --worktree 标志的 boulder 都属于此列。
  • 字段显式为 null"worktree_path": null):可能来自人工编辑 boulder.json、外部工具改写、或状态文件损坏。JSON.parse 得到 null,违反 string | undefined 契约,且能通过 readBoulderState 的全部前置校验(它仍是合法对象,不是空对象)。

传播链:从 boulder.json 到续跑注入

这个 null 值会沿两条路径进入 atlas 的续跑注入链路:

  1. 主路径session.idle 事件 → idle-event.tshandleAtlasSessionIdle()injectContinuation()boulder-continuation-injector.tsinjectBoulderContinuation()
  2. 重试路径idle-event.tsscheduleRetry() 回调 → 同一条注入链。

主路径的取值点在 packages/omo-opencode/src/hooks/atlas/idle-event.tsboulderState.worktree_path 被原样作为 worktreePath 传入 injectContinuation()

最终消费点在 packages/omo-opencode/src/hooks/atlas/boulder-continuation-injector.tsinjectBoulderContinuation 的入参声明为 worktreePath?: string,第 65 行 const worktreeContext = worktreePath ? \...` : ""用 falsy 判断兜底——所以null在"拼提示词"这一步恰好不炸。但 PR 强调的正是:**恰好不炸不等于正确**。类型违约破坏了BoulderState 接口契约,null可能沿getWorkResumeOptions()、镜像投影、后续新增的调用点继续扩散,在任何一处对 worktree_pathstring方法调用(如.length`、字符串拼接之外的处理)时变成隐蔽的运行时故障。这正是"防御前移"要解决的问题。

修复方案:读取边界净化 + 使用点守卫 + 测试补齐

PR 的变更清单如下(继承自 PR 描述的 Changes 表,路径以当前仓库实际位置为准):

文件 变更
src/features/boulder-state/storage.ts(现规范实现位于 packages/boulder-state/src/storage/read-state.ts readBoulderState() 中净化 worktree_path——拒绝非字符串值
packages/omo-opencode/src/hooks/atlas/idle-event.ts 在把 worktree_path 传给 continuation 之前增加 typeof 守卫(2 处调用点)
packages/omo-opencode/src/hooks/atlas/index.test.ts 新增 2 个测试:session.idle 下 worktree_path 缺失 / 为 null
packages/omo-opencode/src/features/boulder-state/storage.test.ts 新增 2 个测试:null 值净化 + 合法字符串保留

净化挂点:normalizeState()

从源码结构看,净化逻辑的自然挂点就是 readBoulderState() 中已有 normalizeState(parsed) 调用(read-state.ts#L21-L29)。normalizeState 目前依次处理 session_idssession_originstask_sessionsworks 内各条目的会话字段;PR 描述的净化即在此处对 worktree_path(以及镜像来源 works 中各 work 的 worktree_path)做 typeof value === "string" ? value : undefined 式的判定——非字符串(null、数字、对象)一律拒绝并置为 undefined,使返回值重新落回 string | undefined 契约内。

需要说明的是:在当前仓库快照中,read-state.ts 仍可看到 const state = parsed as BoulderState 这一被 PR 点名的模式,且 normalizeState 中尚未出现针对 worktree_path 的守卫——结合 shared.ts 中镜像投影仍会无条件拷贝 work.worktree_path 来看,可以推断本 PR 所述的净化与守卫是待合入(或按 PR 清单实施)的增量变更。文章按 PR 描述给出目标行为,并以当前快照作为"修复前"的对照基线。

使用点守卫:双调用点加 typeof 判断

即使读取层净化到位,PR 仍在 atlas 侧的 2 处调用点(handleAtlasSessionIdle 直传与 scheduleRetry 回调)加上 typeof 守卫后才传入 continuation 链路。这是典型的纵深防御:注入点不信任任何上游对状态的保证,只放行 typeof worktreePath === "string" 的值。与注入器层的 falsy 兜底(worktreePath ? ... : "")叠加后,null/undefined/缺失三种形态在链路上每一步都有明确归宿。

为什么净化要放在读取层而不是每个调用点

对照 packages/boulder-state/AGENTS.md 的消费方清单可以看清动机:boulder-state 同时被 omo-opencode(atlas、ulw-execute、todo-continuation-enforcer 等 hooks 与 CLI boulder 命令)、omo-codex(plugin/components/ulw-execute-continuation/boulder-reader.ts)和 omo-senpi 多个消费方读取。如果每个调用点各自防御 worktree_pathnull 仍会污染其余调用点;把净化收敛到 readBoulderState() 这一个入口,所有消费方拿到的状态一次性满足类型契约——这与该函数既有的"拒绝空对象、默认化 session_ids/task_sessions"设计哲学完全一致。

测试覆盖与验证

PR 声明的验证命令与结果:

bun test src/hooks/atlas/          # 全部既有 + 新增测试通过
bun test src/features/boulder-state/  # 全部既有 + 新增测试通过
bun run typecheck                   # clean
bun run build                       # clean

仓库中既有的测试基线也佐证了净化范式的必要性。packages/omo-opencode/src/features/boulder-state/storage.test.tsreadBoulderState 用例已系统性覆盖脏输入:

  • boulder.json 内容为 null → 返回 null
  • 内容为字符串字面量(JSON primitive)→ 返回 null
  • 内容为空对象 {} → 返回 null
  • session_ids 缺失或非数组 → 默认 []
  • 单会话缺失 origin → 回填 direct
  • 多会话缺失 origin → 保持 {}(不猜测)。

PR 新增的 4 个测试沿用同一风格:storage.test.ts 验证"worktree_path: null 被净化"与"合法字符串 worktree_path 原样保留"两条断言;index.test.ts 则在 session.idle 事件层验证缺失与 null 两种状态下 atlas 不崩溃、注入链路照常走通。这类"缺字段"与"显式 null"成对出现的用例设计,正是针对本问题根因(两种缺法语义不同)的直接回应。

工程启示:JSON.parse + as 断言的正确打开方式

这个修复虽小,但完整示范了对"文件系统里的 JSON 是不可信输入"这一事实的处理模式:

  1. 断言不是校验parsed as BoulderState 只服务于编译期;类型契约必须在运行时被显式重建。
  2. 净化收敛到读取边界:所有消费方共享同一个净化入口,脏值在离开 readBoulderState() 之前就被消除,而不是在 N 个调用点重复防御。
  3. 缺失与 null 必须分治worktree_path 缺字段是历史兼容的正常形态(worktree 功能之前的老状态),而 null 是违约形态;正确的契约是"非字符串一律归一为 undefined",而不是"字段必须在"。
  4. 关键调用点保留纵深守卫:像 idle-event.ts 这类直接驱动 LLM 会话续跑的注入点,用 typeof 再做一次廉价检查,成本几乎为零,换来的是对上游回归的免疫。
  5. 测试成对覆盖:每个被净化的字段至少一对用例——"非法值被拒绝"与"合法值被保留",再加事件层的端到端不崩溃用例,三层验证缺一不可。

对照 packages/boulder-state/src/storage/read-state.tspackages/boulder-state/src/types.tspackages/omo-opencode/src/hooks/atlas/ 目录(约 34 个生产模块,atlas 决策门与注入流程详见 hooks/atlas/AGENTS.md),可以完整追溯这条从磁盘 JSON 到会话续跑注入的全链路,以及本次修复落在链路中的精确位置。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
981
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384