首页
/ oh-my-openagent Atlas 续跑崩溃加固解析:readBoulderState 校验、getPlanProgress 类型守卫与 setTimeout 重试容错

oh-my-openagent Atlas 续跑崩溃加固解析:readBoulderState 校验、getPlanProgress 类型守卫与 setTimeout 重试容错

2026-09-04 14:59:32作者:管翌锬

本篇技术文章基于仓库中一份 PR 描述文档(pr-description.md)展开,围绕 oh-my-openagent 的 Atlas 空闲续跑(idle continuation)链路,详解一次典型的“状态文件畸形 → 下游 TypeError → 未处理 Promise 拒绝”崩溃事故及其三层防御式修复:readBoulderState() 字段校验、getPlanProgress() 类型守卫,以及 setTimeout 重试回调的 try/catch 包裹。读完后,你将理解 boulder.json 状态机的读取链路、Atlas hook 的续跑调度机制,以及如何为基于状态文件的多会话 Agent 系统编写健壮的防御式代码。

1. 问题背景:一次由畸形 boulder.json 引发的崩溃

1.1 崩溃链条(Context 原文复述)

PR 文档对问题的描述可以概括为一条完整的崩溃链条:

  1. .omo/boulder.json 被手写编辑或意外写坏、缺失 worktree_path 等字段时,readBoulderState() 把解析结果未经校验地强转为 BoulderState 返回;
  2. 下游调用方(如 Atlas hook)取到 boulderState.active_plan(此时可能是 undefined)后传入 getPlanProgress()
  3. getPlanProgress()undefined 传给 existsSync(),触发 TypeError
  4. 更危险的是,这条调用发生在 idle-event.ts 中未受保护的 setTimeout 重试回调里,错误最终变成 unhandled promise rejection,直接击穿 Atlas hook 的生命周期。

这条链条的关键点在于:状态文件的合法性问题不会在读取时暴露,而是被推迟到最深层的调用(existsSync)才以最难排查的 TypeError 形式爆发

1.2 boulder 状态系统在当前仓库中的位置

当前仓库中,boulder 状态的实现位于独立共享包 packages/boulder-state

  • 状态文件路径由 constants.ts 定义:BOULDER_DIR = ".omo"BOULDER_FILE = "boulder.json",即完整路径为 <项目根>/.omo/boulder.json;计划文件目录为 .omo/plans(旧版兼容 .sisyphus/plans,见 plan-progress.ts 中的 PROMETHEUS_PLAN_DIRS);
  • 状态读取入口 readBoulderState() 位于 read-state.ts
  • 主插件包 packages/omo-opencode 通过 storage.ts 这个 re-export shim 把 readBoulderStategetPlanProgress 等符号转发到共享包(PR 文档中写的 src/features/boulder-state/storage.ts 在仓库演进后已拆分为共享核心 + 转发层,两者功能等价)。

理解这个布局很重要:Atlas hook 并不直接读 JSON 文件,而是通过这一层共享 API 消费状态。因此,把校验逻辑放在 readBoulderState() 这一层,就一次性保护了所有下游调用方(Atlas hook、CLI boulder 子命令、ulw-execute hook 等)。

2. 现状代码走读:为什么 undefined 能一路传到底

在评估修复方案前,先看当前快照中的三条关键路径。

2.1 readBoulderState():解析后直接强转

当前 read-state.ts 的实现逻辑是:

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) || Object.keys(parsed).length === 0) {
      return null
    }

    normalizeState(parsed)
    const state = parsed as BoulderState   // ← 强转点:不校验 active_plan / plan_name / worktree_path
    const mirrorWork = selectMirrorWork(state)
    if (mirrorWork) {
      state.active_work_id = mirrorWork.work_id
      projectWorkToMirror(state, mirrorWork)
    }

    return state
  } catch {
    return null
  }
}

注意 parsed as BoulderState 这一行:它只做了解析层面的防御(非对象、空对象、JSON 语法错误返回 null),但不校验必填字段是否以正确类型存在normalizeState() 只规范化 session_idssession_originstask_sessions 等可选集合字段(read-state.ts),并不涉及 active_planplan_nameworktree_path。因此,一个 {"active_plan": 123} 或干脆缺省 active_plan 的 JSON 会原样通过。

2.2 resolveBoulderPlanPath():依赖字段“总是字符串”的隐含假设

Atlas 链路中计算计划文件路径的函数是 path.tsresolveBoulderPlanPath()

export function resolveBoulderPlanPath(
  directory: string,
  state: Pick<BoulderState, "active_plan" | "worktree_path">,
): string {
  const absolutePlanPath = resolveTrackedPath(directory, state.active_plan)
  const worktreePath = state.worktree_path?.trim()
  if (!worktreePath) {
    return absolutePlanPath
  }
  // ...worktree 镜像路径解析与 existsSync 回退
}

它显式使用了 worktree_path?.trim() 的可选链——这恰恰印证了 PR 文档的论点:worktree_path 本就是可选字段,缺失时走主仓路径回退。但 worktree_path?.trim() 只对“缺失”做了防御,若该字段存在但不是字符串(例如被写成 {"a": "b"} 或数字),.trim() 与后续 resolveTrackedPath(内部执行 resolve(baseDirectory, trackedPath))仍会因类型错误而抛异常。这正是 PR 修复项“worktree_path 存在但类型不是字符串时应剥离该字段”的由来。

2.3 getPlanProgress():existsSync 是崩溃的最后一站

plan-progress.ts

export function getPlanProgress(planPath: string): PlanProgress {
  if (!existsSync(planPath)) {                       // ← planPath 为 undefined 时此处抛 TypeError
    return { total: 0, completed: 0, isComplete: false }
  }
  try {
    const content = readFileSync(planPath, "utf-8")
    const checklist = parsePlanChecklist(content)
    return { total: checklist.total, completed: checklist.completed, isComplete: ... }
  } catch {
    return { total: 0, completed: 0, isComplete: false }
  }
}

可以看到该函数对“文件不存在”“读取/解析失败”都有 fail-soft 兜底,唯独没防住“planPath 本身是 undefined”——existsSync(undefined) 会直接抛出 TypeError: The "path" argument must be of type string,而这一行在 try之外,兜底 catch 覆盖不到它。

2.4 Atlas 空闲续跑链路:崩溃为什么危险

Atlas hook 在会话空闲时决定是否向 tracked 会话注入续跑提示,入口是 idle-event.tshandleAtlasSessionIdle()。其内部逻辑(按源码顺序):

  1. 通过 resolveActiveBoulderSession() 确认当前会话注册在活跃 boulder 中,否则跳过;
  2. 计划进度 progress.isComplete 为真时转入完成提示分支 handleCompletedBoulderIdle()
  3. 存在运行中的后台任务(hasRunningBackgroundTasks)或处于续跑冷却期(CONTINUATION_COOLDOWN_MS 内)时,调用 scheduleRetry() 延迟重试后返回;
  4. 通过 shouldPromptAfterSessionIdle() 确认会话在空闲沉淀期后确实仍处于空闲,才真正执行 injectContinuation()

其中 scheduleRetry()(实现见 idle-continuation.ts)用 setTimeoutRETRY_DELAY_MS 后再次读取 boulder 状态并重试注入。重试延迟与熔断参数集中在 idle-constants.ts

export const CONTINUATION_COOLDOWN_MS = 5000
export const FAILURE_BACKOFF_MS = 5 * 60 * 1000
export const MAX_CONSECUTIVE_PROMPT_FAILURES = 10
export const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000

这个重试机制本身就是“在定时器回调里异步执行”的典型场景——如果回调内的 Promise reject 无人处理,Node.js 会将其上报为 unhandled rejection;在 hook 插件进程中,这类错误轻则污染日志与遥测,重则中断宿主事件循环回调。这就是 PR 文档把该场景称为“especially dangerous”的原因。

3. 修复方案详解:三层防御

3.1 第一层:readBoulderState() 校验必填字符串字段

PR 文档在 src/features/boulder-state/storage.ts 一节给出的变更点:

  • 校验 active_planplan_name 必须是字符串:若解析结果中这两个字段存在但不是字符串类型,则整体返回 null(而非返回残缺状态),让所有调用方走“无活跃 boulder”的既有分支;
  • worktree_path 存在但类型不是字符串时剥离该字段:这与 resolveBoulderPlanPath()worktree_path 的可选链语义一致——缺失与“非法类型”归一化处理,让路径解析自然回退到主仓计划路径。

选择“返回 null”而非“修补字段”是关键设计:active_planplan_name 是 boulder 状态的语义核心(分别指向计划文件与计划名称,续跑提示语、进度统计都依赖它们),缺失它们的状态对象已无法支撑任何下游语义,与其让缺陷在下游各处以不同症状爆发,不如在读取边界一次性判死。这与现有代码风格一致——readBoulderState() 对空对象、非对象、JSON 解析失败本来就返回 null

值得说明的是:从当前仓库快照源码看,readBoulderState() 中尚无这两处显式字符串校验,共享核心实现位于 packages/boulder-state/src/storage/read-state.ts;可以推断该 PR 文档描述的是待合入(或待同步至共享核心)的变更。合入位置应落在共享包而非 packages/omo-opencode 转发层,否则 CLI 与 codex 插件等其他消费方(如 boulder-reader.ts 一类直接读状态的路径)仍会暴露。

3.2 第二层:getPlanProgress() 的类型守卫

PR 变更点:在调用 existsSync 之前增加 typeof planPath !== "string" 守卫。结合现状代码,改动等价于:

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

这一守卫的意义是函数级 fail-soft 的最后防线:即便第一层校验因未来重构或新调用方而失效,getPlanProgress() 也只返回零进度对象,绝不会把 TypeError 抛回调用栈。它与函数体内既有的 catch 兜底形成对称——“路径不合法”与“读取/解析失败”统一收敛到同一个零值进度。

3.3 第三层:setTimeout 重试回调包裹 try/catch

PR 变更点:为 idle-event.tsscheduleRetrysetTimeout 异步回调主体包裹 try/catch。当前快照中 idle-continuation.ts 已能观察到该保护结构:

sessionState.pendingRetryTimer = setTimeout(async () => {
  try {
    sessionState.pendingRetryTimer = undefined
    // ... 读取 boulder、检查进度、注入续跑
  } catch (error) {
    const loggedError = error instanceof Error ? error : String(error)
    log(`[${HOOK_NAME}] Failed during boulder continuation retry`, { sessionID, error: loggedError })
    sessionState.promptFailureCount += 1
    sessionState.lastFailureAt = Date.now()
    scheduleRetry({ ctx, sessionID, sessionState, options })
  }
}, RETRY_DELAY_MS)

注意 catch 分支的三个动作构成完整的失败治理闭环:

  1. 结构化记录日志log 统一带 HOOK_NAME 前缀,便于过滤);
  2. 累加 promptFailureCount 并刷新 lastFailureAt:与 idle-event.ts 中的熔断逻辑联动——连续失败达到 MAX_CONSECUTIVE_PROMPT_FAILURES(10 次)后,在 FAILURE_BACKOFF_MS(5 分钟)退避窗口内直接跳过续跑,避免畸形状态引发的错误无限循环重试;
  3. 再次 scheduleRetry():把计时器重置为下一次探测,直到状态恢复正常或熔断生效。

这三层防御的职责划分可以归纳为:状态层(readBoulderState)负责“不产出坏数据”,工具函数层(getPlanProgress)负责“消费时不炸”,调度层(scheduleRetry 的 try/catch)负责“即便炸了也要可恢复”。任何一层单独存在都只解决部分场景,三者叠加才把“手动编辑 boulder.json”这一高频人为故障场景变成无害事件。

4. 测试覆盖:从状态文件到续跑提示语

PR 文档列出的测试变更及其验证意图:

4.1 storage 层:5 个缺失/畸形字段测试

位置:src/features/boulder-state/storage.test.ts(对应仓库内 storage.test.ts)。该类测试的典型构造方式——仓库现有测试已展示了同一手法,例如 storage.test.ts 中“缺失 session_ids 时默认 []”“task_sessions 缺失时默认空对象”等用例(见文件内 should default session_ids to [] when missing from JSON 等测试名)——即手写一份残缺/畸形 JSON 落盘后调用 readBoulderState() 断言。PR 新增的 5 个用例按文档描述聚焦于:active_plan/plan_name 缺失或类型错误时返回 nullworktree_path 类型错误时被剥离、getPlanProgress(undefined) 返回零值进度而非抛异常。

4.2 Atlas hook 层:续跑提示语中的 worktree_path 语义

PR 新增的 2 个测试位于 src/hooks/atlas/index.test.ts(对应 index.test.ts),验证续跑提示语(continuation prompt)在 worktree_path 存在与缺失两种情况下的正确性。这与续跑注入实现 boulder-continuation-injector.ts 相关:注入时会把 worktreePath 一并传给 injectBoulderContinuation()(见 idle-continuation.tsinjectContinuationinput.worktreePath 的透传),提示语需据此告知 Agent 计划文件位于 worktree 还是主仓。这两个用例的意义在于确认:剥离非法 worktree_path 后,提示语回退到主仓路径的语义依然正确,而不是出现空串或错误路径。

4.3 既有回归面

围绕同一链路,仓库还保留了可直接复用的回归资产:共享包的 read-state.test.tsreadBoulderState 的解析/归一化行为)、Atlas 目录下 idle-event.test.tsidle-continuation.test.ts 等空闲续跑专项测试。若合入本 PR,建议在这两个位置同时补充用例,防止共享核心与插件转发层出现行为漂移(这也是 packages/omo-opencode/src/features/boulder-state/storage.ts 作为纯转发层的价值所在——转发层测试保证“插件消费的符号”与共享包一致)。

5. 工程启示与小结

这次修复虽然只涉及三个函数,但完整演示了状态文件驱动型 Agent 系统的加固套路:

防御层 位置 手段 失效后果(未加固时)
读取边界 readBoulderState()read-state.ts 必填字符串字段校验,非法则返回 null 残缺状态对象流向所有调用方
消费边界 getPlanProgress()plan-progress.ts typeof planPath !== "string" 前置守卫 existsSync(undefined) 抛 TypeError
调度边界 scheduleRetry() 的 setTimeout 回调(idle-continuation.ts try/catch + 失败计数 + 熔断退避 未处理 Promise 拒绝击穿 hook 进程

核心经验有三点:

  1. 把校验放在数据入口,而不是每个消费者——boulder 状态被 Atlas hook、CLI、ulw-execute 等多个模块消费,入口返回 null 的约定让所有调用方的既有“无活跃 boulder”分支免费成为防御分支;
  2. 对可选字段做类型归一——worktree_path 的可选链语义(缺失与非法等价)比逐字段判空更可维护,且与 resolveBoulderPlanPath() 的回退逻辑天然对齐;
  3. 定时器回调是错误治理的特殊区域——setTimeout + async 回调中的异常天然是 unhandled rejection 温床,必须自带 try/catch,并配合失败计数与退避窗口(本仓库为 10 次连续失败 + 5 分钟退避,见 idle-constants.ts)防止坏状态触发无限重试风暴。

适用前提说明:本文所有路径与行为描述以当前仓库快照为准;PR 文档中的 src/features/boulder-state/storage.tssrc/hooks/atlas/idle-event.ts 为插件包内路径,共享核心实现现位于 packages/boulder-statepackages/omo-opencode/src/hooks/atlas/ 下,引用时需注意这一对应关系。

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