oh-my-openagent Atlas Hook 缺失 worktree_path 崩溃修复:从 boulder.json 根因分析到纵深防御的执行计划
本文基于 oh-my-openagent 仓库中 work-with-pr 技能评估工作区产出的一份真实修复执行计划,完整剖析 Atlas hook 在 boulder.json 缺少 worktree_path/active_plan 字段时进程崩溃的根因与调用链,并结合当前仓库源码验证该计划每一步的落点:从 readBoulderState() 的校验缺陷、getPlanProgress() 的防御缺失,到 setTimeout 中悬浮 Promise 导致的未处理拒绝,最终给出"源头失败、边界拦截、异步兜底"的三层防御修复方案与 CI 验证流程。
该计划文件的原始位置是 execution-plan.md,其所在评估工作区还配套了 code-changes.md、pr-description.md 和 verification-strategy.md,构成了一个完整的 PR 工作流产物集。
背景:Atlas Hook 与 boulder.json 状态文件
在 oh-my-openagent 中,Atlas 是一套 hook 机制,负责在会话空闲(session idle)时检测"巨石任务"(boulder)的计划进度,并在未完成时自动注入续跑提示(continuation),让 agent 沿计划继续工作。其状态持久化在项目的 boulder.json 文件中,由 readBoulderState(directory) 读取,核心字段包括:
active_plan:当前计划文件路径,是计算进度的入口;plan_name:计划名称;worktree_path:可选的 git worktree 路径,用于把计划文件定位到 worktree 副本;session_ids/session_origins:关联会话及其来源(direct/appended);works(当前仓库已演进出的多任务结构):按work_id组织的任务状态集合。
路径拼装逻辑在 getBoulderFilePath 中完成。这份执行计划要解决的正是:当 boulder.json 被手动编辑、损坏或写入不完整时(缺少 worktree_path、active_plan 等字段),Atlas hook 链路上的哪一环会崩溃、为什么崩溃、以及如何系统性修复。
Bug 分析:三层缺陷叠加出崩溃
缺陷一:readBoulderState() 校验不足,unsafe cast 直通下游
计划文档指出的根因是 readBoulderState() 解析 boulder.json 时校验过于宽松,原文引用的问题代码形态为:
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 // <-- unsafe cast, no field validation
即只修复了 session_ids,却不校验 active_plan、plan_name、worktree_path。畸形文件(如 {} 或缺少关键字段)会被当作合法的 BoulderState 直通下游,携带 active_plan: undefined 进入 hook 链路。
对照当前仓库实现可以确认这一判断的准确性:现在的 readBoulderState() 位于 read-state.ts(由 packages/omo-opencode/src/features/boulder-state/storage.ts 从 @oh-my-opencode/boulder-state 包统一再导出)。它在原文档基础上已经增加了空对象拦截:
const parsed = JSON.parse(content)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.keys(parsed).length === 0) {
return null
}
并通过 normalizeState 对 session_ids、session_origins、task_sessions、各 work 的会话字段做了归一化,同时用 try/catch 包住整个解析过程(JSON 解析失败直接返回 null)。但对 active_plan/worktree_path 字段级缺失的校验依然没有——一个 {"session_ids": ["abc"]} 这样的文件仍会以 active_plan: undefined 被返回。这正是计划文档 Step 1 的价值所在。
当前代码中部分缓解措施是 getBoulderWorks 在走"legacy mirror"分支时会检查 !state.active_plan || !state.plan_name || !state.started_at 并返回空数组;但这条防线只覆盖 works 视图,不覆盖直接消费 state.active_plan 的调用点。
缺陷二:getPlanProgress(undefined) 触发 TypeError 崩溃路径
计划文档给出的崩溃链是:
boulder.json因手动编辑、损坏或部分写入而缺少必需字段;readBoulderState()返回带active_plan: undefined的BoulderState;- 多个调用点把
boulderState.active_plan传给进度解析函数,文档点名的调用点包括:src/hooks/atlas/idle-event.ts:72(位于setTimeout回调内——未处理的拒绝);src/hooks/atlas/resolve-active-boulder-session.ts:21;src/hooks/atlas/tool-execute-after.ts:74;
getPlanProgress()内部对undefined路径调用existsSync(undefined),抛出TypeError: The "path" argument must be of type string。
在当前仓库中,这条链路仍然成立,且崩溃点可以更精确地定位:
- resolve-active-boulder-session.ts 在确认会话归属后,直接以
resolveBoulderPlanPath(input.directory, nextBoulderState)的结果调用getPlanProgress(); - resolveBoulderPlanPath 第一行就对
state.active_plan执行isAbsolute(trackedPath),active_plan为undefined时这里就会抛 TypeError;值得注意的是它对worktree_path是宽容的——第 20 行state.worktree_path?.trim()用 truthiness 判断,缺失时优雅回退到主目录下的计划路径。也就是说worktree_path本身被妥善处理,真正致命的是同一状态里的active_plan缺失; - getPlanProgress 自身没有任何入参防御:
export function getPlanProgress(planPath: string): PlanProgress {
if (!existsSync(planPath)) { // planPath 为 undefined 时在此抛 TypeError
return { total: 0, completed: 0, isComplete: false }
}
...
}
文件不存在时它能优雅返回零进度,但"路径参数非字符串"这一输入错误没有任何拦截。
缺陷三:setTimeout 异步回调中的悬浮 Promise
计划文档强调的次生问题最具隐蔽性:
sessionState.pendingRetryTimer = setTimeout(async () => {
// ... no try/catch wrapper
const currentBoulder = readBoulderState(ctx.directory)
const currentProgress = getPlanProgress(currentBoulder.active_plan) // CRASH if active_plan undefined
// ...
}, RETRY_DELAY_MS)
setTimeout 的 async 回调会创建一个无人等待的 Promise,回调内任何抛错都变成未处理的 Promise 拒绝(unhandled rejection),直接把进程拖垮——而且发生在定时器延迟触发之后,离最初的 idle 事件很远,极难定位。
值得记录的是:当前仓库中这段 scheduleRetry 实现(已重构到 idle-continuation.ts)已经体现了计划中 Step 2 的修复形态——回调体完整包在 try/catch 中,入口先做 lifecycleActive、失败次数上限、stall 状态、最终波次审批等多重短路检查,随后对 readBoulderState 的结果做了 if (!currentBoulder) return 空值判断(第 188-194 行),catch 分支里记录日志、递增 promptFailureCount 并递归重排重试,形成带退避的失败自愈循环。对照 idle-event.ts 主流程中 scheduleRetry 的三处调度点(后台任务运行时、注入冷却期内),可以看出这条异步链路的错误隔离已经比较完整。这提示读者:计划文档描述的是修复前状态,仓库后续演进已经把"异步层兜底"这一层补上了,但"源头校验"与"边界守卫"两层仍值得按计划在 boulder-state 包内落实。
六步修复计划:从源头到 PR 的完整路线
Step 1:强化 readBoulderState() 的字段校验
目标文件(文档原始路径 src/features/boulder-state/storage.ts,当前落点 read-state.ts):
- 在
session_ids归一化之后,补上active_plan与plan_name(必需字段)的校验; - 校验
worktree_path只能是undefined或字符串(拒绝null、数字等类型污染); - 对缺少必需字段的状态整体返回
null,让上游按"无活跃 boulder"处理。
这个设计原则在现有代码里已有先例可循:read-state.test.ts 中已存在 #given malformed state json #when reading state #then null is returned 的畸形 JSON 用例,字段级校验用例可以与其并列,沿用项目既有的 #given ... #when ... #then ... 命名规范。
Step 2:为 setTimeout 回调补上 try/catch
目标文件(文档路径 src/hooks/atlas/idle-event.ts,setTimeout 逻辑当前在 idle-continuation.ts): 将重试回调主体包入 try/catch,并使用 atlas hook 的 logger 记录错误(现有 catch 分支的写法 log(\[${HOOK_NAME}] Failed during boulder continuation retry`, { sessionID, error: loggedError })` 即是标准范式)。如前所述,当前仓库该处已具备此保护,实施前应先核实现状,避免重复改动。
Step 3:getPlanProgress 增加防御性早退
目标文件(当前 plan-progress.ts): 在函数入口对非字符串 planPath 早退返回零进度对象。这是"边界守卫"层——即使上游校验被绕过,进度解析器自己也不会把 TypeError 抛给调用方,与 existsSync 失败时的优雅降级行为保持一致。
Step 4:测试补齐
文档点名的测试文件及当前仓库对应物:
src/features/boulder-state/storage.test.ts→ 覆盖缺失/畸形字段的用例,当前对应 packages/boulder-state/src/read-state.test.ts;src/hooks/atlas/index.test.ts→ 覆盖"boulder 缺少worktree_path时 atlas hook 仍正常工作"的场景,当前对应 packages/omo-opencode/src/hooks/atlas/index.test.ts,同目录下的 idle-event.test.ts 和 resolve-active-boulder-session.test.ts 也是相关用例的合理落点。
Step 5:运行 CI 检查
计划文档给出的验证命令(项目使用 bun 作为运行时与测试框架,见根目录 bun.lock 与 bunfig.toml):
bun run typecheck
bun test src/features/boulder-state/storage.test.ts # 当前对应 packages/boulder-state/src/read-state.test.ts
bun test src/hooks/atlas/index.test.ts # 当前对应 packages/omo-opencode/src/hooks/atlas/index.test.ts
bun test # 全量测试
Step 6:创建 PR
- 分支:
fix/atlas-hook-missing-worktree-path - 目标分支:
dev - 合入前确认 CI 全绿。
方法论总结:三层纵深防御模式
这份执行计划的价值不仅在于修掉一个崩溃,更在于它示范了对"外部持久化状态不可信"这一前提的系统性应对,可以拆成三层复用:
- 源头失败(fail at the source):读取器对不可信 JSON 做完整字段校验,畸形数据在入口就变成
null,而不是带undefined字段流入全局。对应 Step 1,落点在 read-state.ts; - 边界守卫(guard at the boundary):公开 API(如
getPlanProgress)对自身入参做防御性早退,不因某个调用方失手而抛原生 TypeError。对应 Step 3,落点在 plan-progress.ts; - 异步层兜底(isolate the async layer):所有
setTimeout/定时触发的异步回调必须有 try/catch 与失败计数/退避机制,把未处理拒绝变成可观测、可自愈的重试。对应 Step 2,当前实现可见 idle-continuation.ts,其失败计数与退避常量定义在 idle-constants.ts。
三层中任何一层失守时,其余两层仍能阻止进程崩溃——这正是该计划文档在"Bug Analysis → Step-by-Step Plan"结构中反复强调 try/catch、早退与返回 null 的原因。
延伸阅读路径
- 状态读取与归一化实现:packages/boulder-state/src/storage/read-state.ts
- 路径解析与 worktree 回退逻辑:packages/boulder-state/src/storage/path.ts
- 计划进度解析:packages/boulder-state/src/storage/plan-progress.ts
- Atlas idle 事件主流程:packages/omo-opencode/src/hooks/atlas/idle-event.ts
- 会话归属解析:packages/omo-opencode/src/hooks/atlas/resolve-active-boulder-session.ts
- 状态包文档:packages/boulder-state/AGENTS.md
- 本计划的完整评估工作区(含代码变更、PR 描述与验证策略):.agents/skills/work-with-pr-workspace/iteration-1/eval-2/without_skill/outputs/
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 StartedRust0622
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