首页
/ oh-my-openagent:boulder.json 中 worktree_path 的类型清洗与防御式守卫方案

oh-my-openagent:boulder.json 中 worktree_path 的类型清洗与防御式守卫方案

2026-09-04 16:02:32作者:董斯意

本篇指南围绕 oh-my-openagent 仓库中一份针对 Boulder 状态文件(boulder.json)的代码变更方案展开:当 boulder.jsonworktree_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 } : {}),
}

也就是说,仓库自身的写入逻辑(createBoulderStateaddBoulderWork 等)只会在 worktreePathundefined 时省略该键,永远不会写出 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

这个“类型契约与运行时事实脱节”的下游代价体现在两处:

  1. 计划路径解析resolveBoulderPlanPath 会用 state.worktree_path?.trim() 判断是否需要把计划路径重定位到 worktree 下。可选链对 null 恰好安全,但如果字段是其他非字符串类型(例如数字),.trim() 会直接抛 TypeError
  2. 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

设计动机(引自方案文档)readBoulderStateJSON.parse() 的原始输出直接断言为 BoulderState,不校验具体字段。当 boulder.json 里是 "worktree_path": null(合法 JSON,来源可能是手工编辑、损坏状态或外部工具)时,运行时类型是 null 而 TypeScript 类型是 string | undefined。这段清洗确保下游代码拿到的永远符合类型契约。

值得注意的边界条件:守卫条件是 !== undefined && typeof !== "string",即“字段存在但不是字符串”时才置为 undefined,合法的字符串值原样保留、缺失的字段不引入新键。这与 session_ids 已有的“非数组则重置为空数组”的归一化手法一脉相承——当前仓库的 normalizeState 正是以同样的思路对 session_idssession_originstask_sessions 等字段做逐字段归一化的。

四、变更二:Atlas 空闲钩子的防御式类型守卫

方案的第二层防御是“belt-and-suspenders”(双保险)。即便读取层已经清洗,仓库其他位置的 writeBoulderState 调用仍可能直接产生非法状态,因此在把 worktree_path 传给续跑函数之前再做一次零成本的 typeof 检查。

变更文档涉及 Atlas 空闲钩子中两处 injectContinuation 调用点,当前仓库对应实现位于 idle-event.ts(空闲主路径)与 idle-continuation.tsinjectContinuation / 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 或其他非字符串值泄漏进续跑链路。

从当前源码结构看,这条链路的完整调用关系是:handleAtlasSessionIdleidle-event.ts)先经 resolveActiveBoulderSession 取得 boulderState,再做完成度、停滞、冷却、后台任务等一连串前置判断,最终把 boulderState.worktree_path 透传给 injectContinuationinjectBoulderContinuation,在构造续跑提示词时决定是否追加 [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_pathundefined,续跑提示词中不出现任何 [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 的完整测试面。

六、结合当前仓库源码结构的补充说明

阅读这份方案时,建议对照当前仓库的实际代码组织做三点校准:

  1. 实现位置的演进。方案文档中的路径 src/features/boulder-state/storage.ts 对应当前仓库的 packages/omo-opencode/src/features/boulder-state/storage.ts,该文件目前是一个再导出 shim,把 readBoulderStatewriteBoulderState 等函数统一转出自共享包 @oh-my-opencode/boulder-state(实现位于 packages/boulder-state/src/storage/read-state.tswrite-state.ts)。若在当前仓库落地同类改动,清洗逻辑应加在共享包的 readBoulderState/normalizeState 中,而不是 shim 里。
  2. 状态目录的命名。方案文档的测试夹具使用 SISYPHUS_DIR.sisyphus/boulder.json)指代状态目录;当前仓库测试中状态文件已落在 .omo/boulder.json(见 read-state.test.tsjoin(directory, ".omo") 的夹具写法)。迁移用例时目录名需同步更新,断言逻辑本身不受影响。
  3. 当前实现的可对照点。当前 read-state.tsnormalizeState 已对 session_idssession_originstask_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,不校验路径本身是否合法、是否存在——路径有效性由下游 resolveBoulderPlanPathpath.ts)通过 existsSync 回退逻辑兜底。

小结:这份变更方案的核心价值不在于两行代码本身,而在于它演示了处理“外部可写 JSON 状态文件”的标准姿势——在读取边界把运行时值收敛回类型契约,在关键消费边界再叠加零成本守卫,最后用“缺失字段”和“null 字段”两组测试把契约钉死。对 oh-my-openagent 这类以计划文件 + 状态文件驱动长任务续跑的系统而言,这类防御直接决定了损坏状态下代理是继续安静工作,还是把 [Worktree: null] 之类的脏数据推进模型上下文。

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