首页
/ oh-my-openagent Atlas Hook 缺失 worktree_path 崩溃修复:从 boulder.json 根因分析到纵深防御的执行计划

oh-my-openagent Atlas Hook 缺失 worktree_path 崩溃修复:从 boulder.json 根因分析到纵深防御的执行计划

2026-09-04 12:56:21作者:江焘钦

本文基于 oh-my-openagent 仓库中 work-with-pr 技能评估工作区产出的一份真实修复执行计划,完整剖析 Atlas hook 在 boulder.json 缺少 worktree_path/active_plan 字段时进程崩溃的根因与调用链,并结合当前仓库源码验证该计划每一步的落点:从 readBoulderState() 的校验缺陷、getPlanProgress() 的防御缺失,到 setTimeout 中悬浮 Promise 导致的未处理拒绝,最终给出"源头失败、边界拦截、异步兜底"的三层防御修复方案与 CI 验证流程。

该计划文件的原始位置是 execution-plan.md,其所在评估工作区还配套了 code-changes.mdpr-description.mdverification-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_pathactive_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_planplan_nameworktree_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
}

并通过 normalizeStatesession_idssession_originstask_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 崩溃路径

计划文档给出的崩溃链是:

  1. boulder.json 因手动编辑、损坏或部分写入而缺少必需字段;
  2. readBoulderState() 返回带 active_plan: undefinedBoulderState
  3. 多个调用点把 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
  4. 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_planundefined 时这里就会抛 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)

setTimeoutasync 回调会创建一个无人等待的 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_planplan_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:测试补齐

文档点名的测试文件及当前仓库对应物:

Step 5:运行 CI 检查

计划文档给出的验证命令(项目使用 bun 作为运行时与测试框架,见根目录 bun.lockbunfig.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 全绿。

方法论总结:三层纵深防御模式

这份执行计划的价值不仅在于修掉一个崩溃,更在于它示范了对"外部持久化状态不可信"这一前提的系统性应对,可以拆成三层复用:

  1. 源头失败(fail at the source):读取器对不可信 JSON 做完整字段校验,畸形数据在入口就变成 null,而不是带 undefined 字段流入全局。对应 Step 1,落点在 read-state.ts
  2. 边界守卫(guard at the boundary):公开 API(如 getPlanProgress)对自身入参做防御性早退,不因某个调用方失手而抛原生 TypeError。对应 Step 3,落点在 plan-progress.ts
  3. 异步层兜底(isolate the async layer):所有 setTimeout/定时触发的异步回调必须有 try/catch 与失败计数/退避机制,把未处理拒绝变成可观测、可自愈的重试。对应 Step 2,当前实现可见 idle-continuation.ts,其失败计数与退避常量定义在 idle-constants.ts

三层中任何一层失守时,其余两层仍能阻止进程崩溃——这正是该计划文档在"Bug Analysis → Step-by-Step Plan"结构中反复强调 try/catch、早退与返回 null 的原因。

延伸阅读路径

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

项目优选

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