oh-my-openagent: 让 boulder.json 的 worktree_path 不再让 atlas Hook 崩溃——一次运行时类型防御修复的完整拆解
本文基于 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.json(schema_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_plan、plan_name、session_ids、worktree_path 等)是当前活跃 work 的镜像——selectMirrorWork() 选中活跃 work,projectWorkToMirror() 把它拷贝到根级。镜像投影的实现见 packages/boulder-state/src/storage/shared.ts(state.worktree_path = work.worktree_path 一行直接把 work 上的值原样复制到根级)。这意味着:任何一个 work 的 worktree_path 是 null,镜像投影就会让根级也变成 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 垫片(核心逻辑已抽取到共享包)。其读取流程是:
existsSync(filePath)检查文件是否存在,不存在返回null;readFileSync+JSON.parse(content)得到原始值parsed;- 拒绝非对象、数组、空对象
{}载荷(Object.keys(parsed).length === 0时返回null); - 执行
normalizeState(parsed)做规范化; - 最终
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 的两种缺法,只有一种被正确对待
undefined 和 null 是两回事
- 字段缺失(
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 的续跑注入链路:
- 主路径:
session.idle事件 →idle-event.ts的handleAtlasSessionIdle()→injectContinuation()→boulder-continuation-injector.ts的injectBoulderContinuation(); - 重试路径:
idle-event.ts的scheduleRetry()回调 → 同一条注入链。
主路径的取值点在 packages/omo-opencode/src/hooks/atlas/idle-event.ts:boulderState.worktree_path 被原样作为 worktreePath 传入 injectContinuation()。
最终消费点在 packages/omo-opencode/src/hooks/atlas/boulder-continuation-injector.ts:injectBoulderContinuation 的入参声明为 worktreePath?: string,第 65 行 const worktreeContext = worktreePath ? \...` : ""用 falsy 判断兜底——所以null在"拼提示词"这一步恰好不炸。但 PR 强调的正是:**恰好不炸不等于正确**。类型违约破坏了BoulderState 接口契约,null可能沿getWorkResumeOptions()、镜像投影、后续新增的调用点继续扩散,在任何一处对 worktree_path做string方法调用(如.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_ids、session_origins、task_sessions、works 内各条目的会话字段;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_path,null 仍会污染其余调用点;把净化收敛到 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.ts 的 readBoulderState 用例已系统性覆盖脏输入:
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 是不可信输入"这一事实的处理模式:
- 断言不是校验:
parsed as BoulderState只服务于编译期;类型契约必须在运行时被显式重建。 - 净化收敛到读取边界:所有消费方共享同一个净化入口,脏值在离开
readBoulderState()之前就被消除,而不是在 N 个调用点重复防御。 - 缺失与 null 必须分治:
worktree_path缺字段是历史兼容的正常形态(worktree 功能之前的老状态),而null是违约形态;正确的契约是"非字符串一律归一为undefined",而不是"字段必须在"。 - 关键调用点保留纵深守卫:像
idle-event.ts这类直接驱动 LLM 会话续跑的注入点,用typeof再做一次廉价检查,成本几乎为零,换来的是对上游回归的免疫。 - 测试成对覆盖:每个被净化的字段至少一对用例——"非法值被拒绝"与"合法值被保留",再加事件层的端到端不崩溃用例,三层验证缺一不可。
对照 packages/boulder-state/src/storage/read-state.ts、packages/boulder-state/src/types.ts 与 packages/omo-opencode/src/hooks/atlas/ 目录(约 34 个生产模块,atlas 决策门与注入流程详见 hooks/atlas/AGENTS.md),可以完整追溯这条从磁盘 JSON 到会话续跑注入的全链路,以及本次修复落在链路中的精确位置。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00