oh-my-openagent 中 Atlas 续跑机制加固:boulder.json 缺失 worktree_path 崩溃的完整防御链
本文围绕一次针对 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_plan与plan_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.ts 与 src/hooks/atlas/idle-event.ts);当前仓库中对应实现已拆分为独立包 packages/boulder-state 与 packages/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_plan、plan_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_origins 与 task_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")
})
以及 getPlanProgress 对 undefined 入参的健壮性用例:
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.ts 的 worktreeContext = 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)"四层组合,把一类由脏状态文件引发的进程级崩溃收敛为可控的日志与跳过行为。
七、参考文件
- 状态读取与归一化:read-state.ts
- 计划进度解析:plan-progress.ts
- 路径解析与 worktree 重定位:path.ts
- 状态类型定义:types.ts
- 路径常量:constants.ts
- Atlas 空闲事件主流程:idle-event.ts
- 续跑注入与延迟重试:idle-continuation.ts
- 续跑提示构造(worktree 上下文):boulder-continuation-injector.ts
- 旧入口再导出垫片:storage.ts
适用前提与限制:本文的"修复前/修复后"代码块与测试用例摘自修复记录文档,反映的是当时仓库版本(文件位于 src/ 下的单包布局)的行为;当前仓库已完成模块拆分,boulder-state 相关能力收敛至独立包 packages/boulder-state。在引用具体行为(如 getPlanProgress 对缺失文件的返回值语义、状态文件位于 .omo/boulder.json 而非 .sisyphus/)时,请以当前仓库源码为准。
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