oh-my-openagent:boulder.json 中 worktree_path 的类型清洗与防御式守卫方案
本篇指南围绕 oh-my-openagent 仓库中一份针对 Boulder 状态文件(boulder.json)的代码变更方案展开:当 boulder.json 中 worktree_path 字段被手工编辑、外部工具或损坏状态写成 null 时,readBoulderState() 的无校验类型断言会让运行时值与 TypeScript 类型契约(string | undefined)不一致,进而可能把非法值泄漏进 Atlas 空闲续跑的提示词。读完后,你将掌握这份变更方案中“读取层清洗 + 调用层守卫 + 测试锁定”三层防御的完整设计,并能结合仓库当前源码定位到每个改动点对应的实际实现位置。
一、背景:boulder.json 与 BoulderState 类型契约
oh-my-openagent 用 .omo/boulder.json 状态文件追踪进行中的计划(plan)执行进度,包括活动计划路径、会话列表、计划名,以及可选的执行代理和 worktree 路径。该文件的核心类型定义在共享包 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 // ← 本文档关注点:类型契约为 string | undefined
task_sessions?: Record<string, TaskSessionState>
}
注意 worktree_path?: string:类型系统承诺“要么不存在,要么是字符串”,null 不在合法取值范围内。
为什么 null 会混进来?关键在于写入路径天然规避了它。从 createBoulderState 的实现看,状态构造时对可选字段采用“键省略”策略:
return {
schema_version: 2,
active_work_id: workId,
works: { [workId]: work },
active_plan: planPath,
// ...
...(agent !== undefined ? { agent } : {}),
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
}
也就是说,仓库自身的写入逻辑(createBoulderState、addBoulderWork 等)只会在 worktreePath 为 undefined 时省略该键,永远不会写出 worktree_path: null。但 boulder.json 是一个落在磁盘上的普通 JSON 文件,用户手工编辑、状态文件损坏或第三方工具写入都可能产生完全合法的 JSON:
{
"active_plan": "/path/to/plan.md",
"plan_name": "test-plan",
"worktree_path": null
}
这正是变更方案要防御的输入。
二、问题定位:readBoulderState 的无校验类型断言
读取入口 readBoulderState() 位于共享包 read-state.ts(在 omo-opencode 包中经由 storage.ts 再导出)。其核心流程是:
const content = readFileSync(filePath, "utf-8")
const parsed = JSON.parse(content)
// ... 空对象/数组等顶层结构校验 ...
normalizeState(parsed)
const state = parsed as BoulderState
原始方案文档指出的问题在于:JSON.parse() 的产物被直接 as BoulderState 断言,除顶层“是否为非空对象”之外,不对 worktree_path 等具体字段做运行时校验。当文件里写的是 null 时,state.worktree_path 的运行时类型是 null,而类型签名声称是 string | undefined。
这个“类型契约与运行时事实脱节”的下游代价体现在两处:
- 计划路径解析:resolveBoulderPlanPath 会用
state.worktree_path?.trim()判断是否需要把计划路径重定位到 worktree 下。可选链对null恰好安全,但如果字段是其他非字符串类型(例如数字),.trim()会直接抛TypeError。 - Atlas 空闲续跑注入:Atlas 钩子在会话空闲时读取 boulder 状态并调用
injectContinuation,把worktreePath一路传入续跑提示词构造器。提示词中会拼出[Worktree: ${worktreePath}]文本(见 boulder-continuation-injector.ts),null/非字符串值一旦泄漏,轻则在续跑提示词里渲染出[Worktree: null]之类的噪声上下文,重则引发类型误用。
三、变更一:在 readBoulderState 中清洗 worktree_path
方案的第一层防御放在读取层。变更文档给出的 BEFORE/AFTER 对比(原方案中对应 src/features/boulder-state/storage.ts,即当前仓库的 packages/omo-opencode/src/features/boulder-state/storage.ts)如下:
// BEFORE (lines 29-32):
if (!Array.isArray(parsed.session_ids)) {
parsed.session_ids = []
}
return parsed as BoulderState
// AFTER:
if (!Array.isArray(parsed.session_ids)) {
parsed.session_ids = []
}
if (parsed.worktree_path !== undefined && typeof parsed.worktree_path !== "string") {
parsed.worktree_path = undefined
}
return parsed as BoulderState
设计动机(引自方案文档):readBoulderState 把 JSON.parse() 的原始输出直接断言为 BoulderState,不校验具体字段。当 boulder.json 里是 "worktree_path": null(合法 JSON,来源可能是手工编辑、损坏状态或外部工具)时,运行时类型是 null 而 TypeScript 类型是 string | undefined。这段清洗确保下游代码拿到的永远符合类型契约。
值得注意的边界条件:守卫条件是 !== undefined && typeof !== "string",即“字段存在但不是字符串”时才置为 undefined,合法的字符串值原样保留、缺失的字段不引入新键。这与 session_ids 已有的“非数组则重置为空数组”的归一化手法一脉相承——当前仓库的 normalizeState 正是以同样的思路对 session_ids、session_origins、task_sessions 等字段做逐字段归一化的。
四、变更二:Atlas 空闲钩子的防御式类型守卫
方案的第二层防御是“belt-and-suspenders”(双保险)。即便读取层已经清洗,仓库其他位置的 writeBoulderState 调用仍可能直接产生非法状态,因此在把 worktree_path 传给续跑函数之前再做一次零成本的 typeof 检查。
变更文档涉及 Atlas 空闲钩子中两处 injectContinuation 调用点,当前仓库对应实现位于 idle-event.ts(空闲主路径)与 idle-continuation.ts(injectContinuation / scheduleRetry 定义处):
位置 1:scheduleRetry 内的续跑注入(方案文档标注原文件 lines 83-88)
// BEFORE:
await injectContinuation({
ctx,
sessionID,
sessionState,
options,
planName: currentBoulder.plan_name,
progress: currentProgress,
agent: currentBoulder.agent,
worktreePath: currentBoulder.worktree_path,
})
// AFTER:
await injectContinuation({
ctx,
sessionID,
sessionState,
options,
planName: currentBoulder.plan_name,
progress: currentProgress,
agent: currentBoulder.agent,
worktreePath: typeof currentBoulder.worktree_path === "string" ? currentBoulder.worktree_path : undefined,
})
位置 2:handleAtlasSessionIdle 内的续跑注入(方案文档标注原文件 lines 184-188)
// BEFORE:
await injectContinuation({
ctx,
sessionID,
sessionState,
options,
planName: boulderState.plan_name,
progress,
agent: boulderState.agent,
worktreePath: boulderState.worktree_path,
})
// AFTER:
await injectContinuation({
ctx,
sessionID,
sessionState,
options,
planName: boulderState.plan_name,
progress,
agent: boulderState.agent,
worktreePath: typeof boulderState.worktree_path === "string" ? boulderState.worktree_path : undefined,
})
设计动机(引自方案文档):双保险防御。即使 readBoulderState 已经做了清洗,别处的直接 writeBoulderState 调用仍可能产生非法状态。typeof 检查零成本,能彻底杜绝 null 或其他非字符串值泄漏进续跑链路。
从当前源码结构看,这条链路的完整调用关系是:handleAtlasSessionIdle(idle-event.ts)先经 resolveActiveBoulderSession 取得 boulderState,再做完成度、停滞、冷却、后台任务等一连串前置判断,最终把 boulderState.worktree_path 透传给 injectContinuation → injectBoulderContinuation,在构造续跑提示词时决定是否追加 [Worktree: ...] 上下文。守卫点选在 injectContinuation 的入参处,恰好卡在“状态值”与“提示词文本”的交界处。
五、变更三与四:用测试锁定两类损坏状态
方案文档同时给出了两组测试用例,分别锁定“字段缺失”与“字段为 null”两种场景。
5.1 Atlas 钩子层测试(index.test.ts)
在既有 session.idle handler 的 describe 块中新增两个用例:
test("should inject continuation when boulder.json has no worktree_path field", async () => {
// given - boulder state WITHOUT worktree_path
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const readState = readBoulderState(TEST_DIR)
expect(readState?.worktree_path).toBeUndefined()
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then - continuation injected, no worktree context in prompt
expect(mockInput._promptMock).toHaveBeenCalled()
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.body.parts[0].text).not.toContain("[Worktree:")
expect(callArgs.body.parts[0].text).toContain("1 remaining")
})
test("should handle boulder.json with worktree_path: null without crashing", async () => {
// given - manually write boulder.json with worktree_path: null (corrupted state)
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
const boulderPath = join(SISYPHUS_DIR, "boulder.json")
writeFileSync(boulderPath, JSON.stringify({
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
worktree_path: null,
}, null, 2))
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then - should inject continuation without crash, no "[Worktree: null]"
expect(mockInput._promptMock).toHaveBeenCalled()
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.body.parts[0].text).not.toContain("[Worktree: null]")
expect(callArgs.body.parts[0].text).not.toContain("[Worktree: undefined]")
})
这两个用例的断言设计各有侧重:
- 缺失字段用例:验证正常写入路径下
readBoulderState读回的worktree_path是undefined,续跑提示词中不出现任何[Worktree:上下文,同时计划进度(1 remaining,对应- [ ] Task 1一条未完成任务)仍然被正确注入——证明清洗逻辑没有误伤正常流程。 - null 字段用例:绕过
writeBoulderState直接手写boulder.json注入worktree_path: null(模拟损坏状态),验证空闲处理器不崩溃,且提示词中既不渲染[Worktree: null]也不渲染[Worktree: undefined]——即null被规范化成了“无 worktree 上下文”。
测试文件在仓库中的对应位置是 packages/omo-opencode/src/hooks/atlas/index.test.ts。
5.2 存储层测试(storage.test.ts 增补)
describe("#given boulder.json with worktree_path: null", () => {
test("#then readBoulderState should sanitize null to undefined", () => {
// given
const boulderPath = join(TEST_DIR, ".sisyphus", "boulder.json")
writeFileSync(boulderPath, JSON.stringify({
active_plan: "/path/to/plan.md",
started_at: "2026-01-02T10:00:00Z",
session_ids: ["session-1"],
plan_name: "test-plan",
worktree_path: null,
}, null, 2))
// when
const state = readBoulderState(TEST_DIR)
// then
expect(state).not.toBeNull()
expect(state!.worktree_path).toBeUndefined()
})
test("#then readBoulderState should preserve valid worktree_path string", () => {
// given
const boulderPath = join(TEST_DIR, ".sisyphus", "boulder.json")
writeFileSync(boulderPath, JSON.stringify({
active_plan: "/path/to/plan.md",
started_at: "2026-01-02T10:00:00Z",
session_ids: ["session-1"],
plan_name: "test-plan",
worktree_path: "/valid/worktree/path",
}, null, 2))
// when
const state = readBoulderState(TEST_DIR)
// then
expect(state?.worktree_path).toBe("/valid/worktree/path")
})
})
存储层测试成对出现:一个证明 null 被清洗为 undefined,另一个证明合法字符串(/valid/worktree/path)被原样保留,防止清洗逻辑写成“一刀切”地把字段直接删掉。该测试增补的落点是 packages/omo-opencode/src/features/boulder-state/storage.test.ts,而共享包侧已有的读取测试可参见 packages/boulder-state/src/read-state.test.ts——其中包含了“文件不存在返回 null”“malformed JSON 返回 null”等既有边界场景,新增用例与它们共同构成 readBoulderState 的完整测试面。
六、结合当前仓库源码结构的补充说明
阅读这份方案时,建议对照当前仓库的实际代码组织做三点校准:
- 实现位置的演进。方案文档中的路径
src/features/boulder-state/storage.ts对应当前仓库的 packages/omo-opencode/src/features/boulder-state/storage.ts,该文件目前是一个再导出 shim,把readBoulderState、writeBoulderState等函数统一转出自共享包@oh-my-opencode/boulder-state(实现位于 packages/boulder-state/src/storage/read-state.ts 与 write-state.ts)。若在当前仓库落地同类改动,清洗逻辑应加在共享包的readBoulderState/normalizeState中,而不是 shim 里。 - 状态目录的命名。方案文档的测试夹具使用
SISYPHUS_DIR(.sisyphus/boulder.json)指代状态目录;当前仓库测试中状态文件已落在.omo/boulder.json(见 read-state.test.ts 中join(directory, ".omo")的夹具写法)。迁移用例时目录名需同步更新,断言逻辑本身不受影响。 - 当前实现的可对照点。当前 read-state.ts 的
normalizeState已对session_ids、session_origins、task_sessions等字段做了逐字段归一化,但尚未包含worktree_path的字符串类型清洗;当前 idle-event.ts 透传给injectContinuation的仍是boulderState.worktree_path原始值。也就是说,方案文档所描述的 AFTER 形态与当前仓库实现之间仍存在明确的落地空间,这也解释了为何该方案的三层结构(读取层清洗、调用层守卫、双向测试锁定)值得作为一个整体来参考。
七、验证方式与适用前提
- 验证:在仓库根目录下运行相关包的测试即可验证改动点。钩子层用 packages/omo-opencode/src/hooks/atlas/index.test.ts 中的
session.idle handler用例覆盖端到端行为(手写损坏 JSON → 触发session.idle→ 断言提示词文本);存储层用readBoulderState的清洗用例覆盖“null 归 undefined、字符串保留”两条契约。 - 适用前提:本方案假设
boulder.json可能被仓库自身之外的途径写入(手工编辑、外部工具),因此不信任磁盘上的任何字段类型;所有清洗都遵循“非法即置 undefined”的宽松降级策略,不做拒绝或抛错,保证损坏状态下的可用性优先。 - 限制:
typeof守卫只保证值要么是字符串要么是undefined,不校验路径本身是否合法、是否存在——路径有效性由下游resolveBoulderPlanPath(path.ts)通过existsSync回退逻辑兜底。
小结:这份变更方案的核心价值不在于两行代码本身,而在于它演示了处理“外部可写 JSON 状态文件”的标准姿势——在读取边界把运行时值收敛回类型契约,在关键消费边界再叠加零成本守卫,最后用“缺失字段”和“null 字段”两组测试把契约钉死。对 oh-my-openagent 这类以计划文件 + 状态文件驱动长任务续跑的系统而言,这类防御直接决定了损坏状态下代理是继续安静工作,还是把 [Worktree: null] 之类的脏数据推进模型上下文。
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 StartedRust0623
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