oh-my-openagent Atlas 续跑崩溃加固解析:readBoulderState 校验、getPlanProgress 类型守卫与 setTimeout 重试容错
本篇技术文章基于仓库中一份 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 文档对问题的描述可以概括为一条完整的崩溃链条:
- 当
.omo/boulder.json被手写编辑或意外写坏、缺失worktree_path等字段时,readBoulderState()把解析结果未经校验地强转为BoulderState返回; - 下游调用方(如 Atlas hook)取到
boulderState.active_plan(此时可能是undefined)后传入getPlanProgress(); getPlanProgress()把undefined传给existsSync(),触发TypeError;- 更危险的是,这条调用发生在
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 把readBoulderState、getPlanProgress等符号转发到共享包(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_ids、session_origins、task_sessions 等可选集合字段(read-state.ts),并不涉及 active_plan、plan_name、worktree_path。因此,一个 {"active_plan": 123} 或干脆缺省 active_plan 的 JSON 会原样通过。
2.2 resolveBoulderPlanPath():依赖字段“总是字符串”的隐含假设
Atlas 链路中计算计划文件路径的函数是 path.ts 的 resolveBoulderPlanPath():
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 是崩溃的最后一站
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.ts 的 handleAtlasSessionIdle()。其内部逻辑(按源码顺序):
- 通过
resolveActiveBoulderSession()确认当前会话注册在活跃 boulder 中,否则跳过; - 计划进度
progress.isComplete为真时转入完成提示分支handleCompletedBoulderIdle(); - 存在运行中的后台任务(
hasRunningBackgroundTasks)或处于续跑冷却期(CONTINUATION_COOLDOWN_MS内)时,调用scheduleRetry()延迟重试后返回; - 通过
shouldPromptAfterSessionIdle()确认会话在空闲沉淀期后确实仍处于空闲,才真正执行injectContinuation()。
其中 scheduleRetry()(实现见 idle-continuation.ts)用 setTimeout 在 RETRY_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_plan与plan_name必须是字符串:若解析结果中这两个字段存在但不是字符串类型,则整体返回null(而非返回残缺状态),让所有调用方走“无活跃 boulder”的既有分支; worktree_path存在但类型不是字符串时剥离该字段:这与resolveBoulderPlanPath()对worktree_path的可选链语义一致——缺失与“非法类型”归一化处理,让路径解析自然回退到主仓计划路径。
选择“返回 null”而非“修补字段”是关键设计:active_plan 与 plan_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.ts 中 scheduleRetry 的 setTimeout 异步回调主体包裹 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 分支的三个动作构成完整的失败治理闭环:
- 结构化记录日志(
log统一带HOOK_NAME前缀,便于过滤); - 累加
promptFailureCount并刷新lastFailureAt:与idle-event.ts中的熔断逻辑联动——连续失败达到MAX_CONSECUTIVE_PROMPT_FAILURES(10 次)后,在FAILURE_BACKOFF_MS(5 分钟)退避窗口内直接跳过续跑,避免畸形状态引发的错误无限循环重试; - 再次
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 缺失或类型错误时返回 null、worktree_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.ts 中 injectContinuation 对 input.worktreePath 的透传),提示语需据此告知 Agent 计划文件位于 worktree 还是主仓。这两个用例的意义在于确认:剥离非法 worktree_path 后,提示语回退到主仓路径的语义依然正确,而不是出现空串或错误路径。
4.3 既有回归面
围绕同一链路,仓库还保留了可直接复用的回归资产:共享包的 read-state.test.ts(readBoulderState 的解析/归一化行为)、Atlas 目录下 idle-event.test.ts、idle-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 进程 |
核心经验有三点:
- 把校验放在数据入口,而不是每个消费者——boulder 状态被 Atlas hook、CLI、ulw-execute 等多个模块消费,入口返回
null的约定让所有调用方的既有“无活跃 boulder”分支免费成为防御分支; - 对可选字段做类型归一——
worktree_path的可选链语义(缺失与非法等价)比逐字段判空更可维护,且与 resolveBoulderPlanPath() 的回退逻辑天然对齐; - 定时器回调是错误治理的特殊区域——
setTimeout+async回调中的异常天然是 unhandled rejection 温床,必须自带 try/catch,并配合失败计数与退避窗口(本仓库为 10 次连续失败 + 5 分钟退避,见 idle-constants.ts)防止坏状态触发无限重试风暴。
适用前提说明:本文所有路径与行为描述以当前仓库快照为准;PR 文档中的 src/features/boulder-state/storage.ts、src/hooks/atlas/idle-event.ts 为插件包内路径,共享核心实现现位于 packages/boulder-state 与 packages/omo-opencode/src/hooks/atlas/ 下,引用时需注意这一对应关系。
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