首页
/ oh-my-openagent 中 Atlas 续跑机制加固:boulder.json 缺失 worktree_path 崩溃的完整防御链

oh-my-openagent 中 Atlas 续跑机制加固:boulder.json 缺失 worktree_path 崩溃的完整防御链

2026-09-04 11:14:19作者:滕妙奇

本文围绕一次针对 Atlas 空闲续跑(idle continuation)钩子的缺陷修复展开:当 boulder.json 状态文件中缺少或携带非法类型的 worktree_path 字段时,异步重试回调中会出现 existsSync(undefined) 这类 TypeError,并进一步演变为未处理的 Promise rejection,最终可能导致进程崩溃。读完后,你将理解这条崩溃链的成因、四层防御式修复(状态校验、异步回调兜底、公共 API 防护、测试补全)的具体代码,以及这些修复在 oh-my-openagent 当前源码中的落地形态。

一、背景:Atlas 续跑如何依赖 boulder 状态

oh-my-openagent 的 Atlas 钩子会在会话空闲(session.idle)事件触发时,检查会话是否关联到一个"正在推进的 boulder"(即一个尚未完成的计划文件),若是,则自动注入一条续跑提示(continuation prompt),让 Agent 继续执行剩余任务。这条链路的核心数据来源就是 boulder 状态文件,其读取入口是 readBoulderState(directory),当前实现位于共享包 @oh-my-opencode/boulder-state:

  • 状态文件路径常量:.omo/boulder.json,见 constants.ts;
  • 状态读取实现:read-state.ts;
  • 状态类型定义 BoulderState(types.ts)中,active_planplan_name 为必填字段,而 worktree_path 是可选字段(worktree_path?: string)。

worktree_path 的用途在 path.ts 中体现:resolveBoulderPlanPath 会在存在 worktree_path 时,尝试把相对计划路径重定位到 worktree 目录下;在续跑提示构造时,boulder-continuation-injector.ts 会把 [Worktree: ${worktreePath}] 追加进提示文本。

问题在于:重试回调里直接把 currentBoulder.worktree_path 透传给下游逻辑,一旦状态文件中该字段为 null、数字或其他非字符串值,下游的 existsSync 调用就会收到非法入参。修复文档记录的正是围绕这一崩溃链的四组改动。需要说明的是,文档中的"Before/After"代码基于重构前的文件布局(src/features/boulder-state/storage.tssrc/hooks/atlas/idle-event.ts);当前仓库中对应实现已拆分为独立包 packages/boulder-statepackages/omo-opencode/src/hooks/atlas/ 下的多文件模块,src/features/boulder-state/storage.ts 现在只是一个再导出垫片,见 storage.ts

二、修复 1:加固 readBoulderState() 的边界校验

修复前

export function readBoulderState(directory: string): BoulderState | null {
  const filePath = getBoulderFilePath(directory)

  if (!existsSync(filePath)) {
    return null
  }

  try {
    const content = readFileSync(filePath, "utf-8")
    const parsed = JSON.parse(content)
    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
      return null
    }
    if (!Array.isArray(parsed.session_ids)) {
      parsed.session_ids = []
    }
    return parsed as BoulderState
  } catch {
    return null
  }
}

修复后

export function readBoulderState(directory: string): BoulderState | null {
  const filePath = getBoulderFilePath(directory)

  if (!existsSync(filePath)) {
    return null
  }

  try {
    const content = readFileSync(filePath, "utf-8")
    const parsed = JSON.parse(content)
    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
      return null
    }
    if (typeof parsed.active_plan !== "string" || typeof parsed.plan_name !== "string") {
      return null
    }
    if (!Array.isArray(parsed.session_ids)) {
      parsed.session_ids = []
    }
    if (parsed.worktree_path !== undefined && typeof parsed.worktree_path !== "string") {
      delete parsed.worktree_path
    }
    return parsed as BoulderState
  } catch {
    return null
  }
}

设计意图:校验必填字段(active_planplan_name)必须是字符串,不满足则直接返回 null(调用方会跳过续跑,而非带着脏数据继续);对可选字段 worktree_path 采取"净化"策略——若存在但不是字符串(如 null、数字),则删除该键,而不是让 undefined/非法值泄漏到下游,从根源上避免 existsSync(undefined) 引发的 TypeError。这与 BoulderState 类型定义中"active_plan/plan_name 必填、worktree_path 可选"的约束完全对齐,见 types.ts

当前仓库的演进:今天的 read-state.ts 在保持"解析失败返回 null"语义的基础上进一步做了结构化归一化:normalizeState 会过滤并规范化 session_ids、归一化 session_originstask_sessions,并在存在多 work 结构时通过 selectMirrorWork 把活跃 work 投影回顶层镜像字段。也就是说,文档中"在边界处保证类型安全"的原则被沿用并扩展成了完整的状态归一化层。

三、修复 2:为 setTimeout 异步重试回调加 try/catch

修复前

sessionState.pendingRetryTimer = setTimeout(async () => {
    sessionState.pendingRetryTimer = undefined

    if (sessionState.promptFailureCount >= 2) return
    if (sessionState.waitingForFinalWaveApproval) return

    const currentBoulder = readBoulderState(ctx.directory)
    if (!currentBoulder) return
    if (!currentBoulder.session_ids?.includes(sessionID)) return

    const currentProgress = getPlanProgress(currentBoulder.active_plan)
    if (currentProgress.isComplete) return
    if (options?.isContinuationStopped?.(sessionID)) return
    if (options?.shouldSkipContinuation?.(sessionID)) return
    if (hasRunningBackgroundTasks(sessionID, options)) return

    await injectContinuation({
      ctx,
      sessionID,
      sessionState,
      options,
      planName: currentBoulder.plan_name,
      progress: currentProgress,
      agent: currentBoulder.agent,
      worktreePath: currentBoulder.worktree_path,
    })
  }, RETRY_DELAY_MS)

修复后

sessionState.pendingRetryTimer = setTimeout(async () => {
    sessionState.pendingRetryTimer = undefined

    try {
      if (sessionState.promptFailureCount >= 2) return
      if (sessionState.waitingForFinalWaveApproval) return

      const currentBoulder = readBoulderState(ctx.directory)
      if (!currentBoulder) return
      if (!currentBoulder.session_ids?.includes(sessionID)) return

      const currentProgress = getPlanProgress(currentBoulder.active_plan)
      if (currentProgress.isComplete) return
      if (options?.isContinuationStopped?.(sessionID)) return
      if (options?.shouldSkipContinuation?.(sessionID)) return
      if (hasRunningBackgroundTasks(sessionID, options)) return

      await injectContinuation({
        ctx,
        sessionID,
        sessionState,
        options,
        planName: currentBoulder.plan_name,
        progress: currentProgress,
        agent: currentBoulder.agent,
        worktreePath: currentBoulder.worktree_path,
      })
    } catch (error) {
      log(`[${HOOK_NAME}] Retry continuation failed`, { sessionID, error: String(error) })
    }
  }, RETRY_DELAY_MS)

设计意图:setTimeout 的异步回调返回的 Promise 是一个"游离 promise"(floating promise)——没有调用方去 catch 它。回调内任何异常(例如 readBoulderState 尚未加固时 existsSync(undefined) 抛出的 TypeError,或 injectContinuation 内部的任意失败)都会变成 unhandled rejection,在严格环境下足以让进程崩溃。即便修复 1 已经堵住了已知的脏数据入口,这里仍是最后一道安全网:任何未预料的异常都被降级为一条日志,而不是进程级故障。

当前仓库的落地形态:该重试逻辑已抽取为独立的 scheduleRetry 函数,位于 idle-continuation.ts。可以确认,当前实现中 setTimeout 回调内已完整包裹 try/catch,且失败处理比修复文档中的版本更进一步:捕获异常后会记录日志、累加 promptFailureCount、更新 lastFailureAt,并在满足条件时再次 scheduleRetry,形成"记录失败 → 指数式退避 → 重试"的闭环。而 idle-event.ts 主流程在触发续跑前,会依次检查最终波次审批等待、计划完成度、后台任务运行状态、续跑冷却等条件,条件不满足时统一走 scheduleRetry 延迟重试——这正是文档中 setTimeout 回调所承担的职责在重构后的位置。

四、修复 3:getPlanProgress 的防御性守卫

修复前

export function getPlanProgress(planPath: string): PlanProgress {
  if (!existsSync(planPath)) {
    return { total: 0, completed: 0, isComplete: true }
  }

修复后

export function getPlanProgress(planPath: string): PlanProgress {
  if (typeof planPath !== "string" || !existsSync(planPath)) {
    return { total: 0, completed: 0, isComplete: true }
  }

设计意图:纵深防御。虽然修复 1 之后 readBoulderState 已保证 active_plan 是字符串,但 getPlanProgress 是一个公共 API,可能被其他调用路径以非法入参(如 undefined)调用。在 existsSync 之前先做 typeof 检查,可以防止 existsSync(undefined) 抛出的 TypeError 从公共边界逃逸。

当前仓库对照:getPlanProgress 现位于 plan-progress.ts。需要注意一处语义差异:当前实现在文件缺失时返回 { total: 0, completed: 0, isComplete: false }(即"计划文件不存在"被视为未完成,交由上游继续判断),这与修复文档中修复前/后的 isComplete: true 返回值不同——说明后续演进调整了"空计划是否算完成"的判定语义。引用该行为时应以当前源码为准,而不是本文档记录的旧版本。

五、修复 4:新增测试用例

存储层校验测试

针对 readBoulderState 的四个用例(原文件路径:src/features/boulder-state/storage.test.ts,当前仓库中对应测试分散在 read-state.test.ts 等文件):

test("should return null when active_plan is missing", () => {
  // given - boulder.json without active_plan
  const boulderFile = join(SISYPHUS_DIR, "boulder.json")
  writeFileSync(boulderFile, JSON.stringify({
    started_at: "2026-01-01T00:00:00Z",
    session_ids: ["ses-1"],
    plan_name: "plan",
  }))

  // when
  const result = readBoulderState(TEST_DIR)

  // then
  expect(result).toBeNull()
})

test("should return null when plan_name is missing", () => {
  // given - boulder.json without plan_name
  const boulderFile = join(SISYPHUS_DIR, "boulder.json")
  writeFileSync(boulderFile, JSON.stringify({
    active_plan: "/path/to/plan.md",
    started_at: "2026-01-01T00:00:00Z",
    session_ids: ["ses-1"],
  }))

  // when
  const result = readBoulderState(TEST_DIR)

  // then
  expect(result).toBeNull()
})

test("should strip non-string worktree_path from boulder state", () => {
  // given - boulder.json with worktree_path set to null
  const boulderFile = join(SISYPHUS_DIR, "boulder.json")
  writeFileSync(boulderFile, JSON.stringify({
    active_plan: "/path/to/plan.md",
    started_at: "2026-01-01T00:00:00Z",
    session_ids: ["ses-1"],
    plan_name: "plan",
    worktree_path: null,
  }))

  // when
  const result = readBoulderState(TEST_DIR)

  // then
  expect(result).not.toBeNull()
  expect(result!.worktree_path).toBeUndefined()
})

test("should preserve valid worktree_path string", () => {
  // given - boulder.json with valid worktree_path
  const boulderFile = join(SISYPHUS_DIR, "boulder.json")
  writeFileSync(boulderFile, JSON.stringify({
    active_plan: "/path/to/plan.md",
    started_at: "2026-01-01T00:00:00Z",
    session_ids: ["ses-1"],
    plan_name: "plan",
    worktree_path: "/valid/worktree/path",
  }))

  // when
  const result = readBoulderState(TEST_DIR)

  // then
  expect(result).not.toBeNull()
  expect(result!.worktree_path).toBe("/valid/worktree/path")
})

以及 getPlanProgressundefined 入参的健壮性用例:

test("should handle undefined planPath without crashing", () => {
  // given - undefined as planPath (from malformed boulder state)

  // when
  const progress = getPlanProgress(undefined as unknown as string)

  // then
  expect(progress.total).toBe(0)
  expect(progress.isComplete).toBe(true)
})

测试覆盖的四个边界恰好对应修复点:缺 active_plan → 拒绝、缺 plan_name → 拒绝、worktree_path: null → 净化为 undefined、合法字符串 → 原样保留。文档中使用的 SISYPHUS_DIR 变量名反映了历史路径 .sisyphus/;当前状态文件路径常量已改为 .omo/boulder.json(见 constants.ts),而 .sisyphus/plans 仍作为遗留计划目录被 plan-progress.ts 兼容读取。

Atlas 钩子层的 worktree 上下文测试

针对钩子行为(原文件路径:src/hooks/atlas/index.test.ts,当前仓库中对应 index.test.ts 等测试文件):

test("should handle boulder state without worktree_path gracefully", async () => {
  // given - boulder state with incomplete plan, no 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",
    // worktree_path intentionally omitted
  }
  writeBoulderState(TEST_DIR, state)

  const mockInput = createMockPluginInput()
  const hook = createAtlasHook(mockInput)

  // when
  await hook.handler({
    event: {
      type: "session.idle",
      properties: { sessionID: MAIN_SESSION_ID },
    },
  })

  // then - should call prompt without crashing, continuation should not contain worktree context
  expect(mockInput._promptMock).toHaveBeenCalled()
  const callArgs = mockInput._promptMock.mock.calls[0][0]
  expect(callArgs.body.parts[0].text).toContain("incomplete tasks")
  expect(callArgs.body.parts[0].text).not.toContain("[Worktree:")
})

test("should include worktree context when worktree_path is present in boulder state", async () => {
  // given - boulder state with worktree_path
  const planPath = join(TEST_DIR, "test-plan.md")
  writeFileSync(planPath, "# Plan\n- [ ] Task 1")

  const state: BoulderState = {
    active_plan: planPath,
    started_at: "2026-01-02T10:00:00Z",
    session_ids: [MAIN_SESSION_ID],
    plan_name: "test-plan",
    worktree_path: "/some/worktree/path",
  }
  writeBoulderState(TEST_DIR, state)

  const mockInput = createMockPluginInput()
  const hook = createAtlasHook(mockInput)

  // when
  await hook.handler({
    event: {
      type: "session.idle",
      properties: { sessionID: MAIN_SESSION_ID },
    },
  })

  // then - should include worktree context in continuation prompt
  expect(mockInput._promptMock).toHaveBeenCalled()
  const callArgs = mockInput._promptMock.mock.calls[0][0]
  expect(callArgs.body.parts[0].text).toContain("[Worktree: /some/worktree/path]")
})

这两个用例验证的是端到端行为:无 worktree_path 时续跑提示正常发出且不包含 worktree 上下文;有 worktree_path 时提示中出现 [Worktree: /some/worktree/path]。后者与当前源码中 boulder-continuation-injector.tsworktreeContext = worktreePath ? \n\n[Worktree: ${worktreePath}] : "" 实现严格对应。

六、改动汇总

文件 改动 规模
src/features/boulder-state/storage.ts 必填字段校验 + worktree_path 净化 + getPlanProgress 守卫 约 8 行新增
src/hooks/atlas/idle-event.ts setTimeout 异步回调包裹 try/catch 约 4 行新增
src/features/boulder-state/storage.test.ts 5 个校验测试 约 60 行新增
src/hooks/atlas/index.test.ts 2 个 worktree_path 处理测试 约 50 行新增

合计:约 4 行生产逻辑变更、约 8 行防御性代码、约 110 行测试代码。这是一次典型的"低成本高收益"加固:生产代码改动极小,但通过"数据入口校验(修复 1)+ 异步边界兜底(修复 2)+ 公共 API 防护(修复 3)+ 回归测试(修复 4)"四层组合,把一类由脏状态文件引发的进程级崩溃收敛为可控的日志与跳过行为。

七、参考文件

适用前提与限制:本文的"修复前/修复后"代码块与测试用例摘自修复记录文档,反映的是当时仓库版本(文件位于 src/ 下的单包布局)的行为;当前仓库已完成模块拆分,boulder-state 相关能力收敛至独立包 packages/boulder-state。在引用具体行为(如 getPlanProgress 对缺失文件的返回值语义、状态文件位于 .omo/boulder.json 而非 .sisyphus/)时,请以当前仓库源码为准。

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

项目优选

收起
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
982
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384