oh-my-openagent 续跑注入 Fail-Safe:非可重试请求错误分类与停止再注入机制全解析

发布时间:2026/9/19 9:33:55
oh-my-openagent 续跑注入 Fail-Safe:非可重试请求错误分类与停止再注入机制全解析 oh-my-openagent 续跑注入 Fail-Safe非可重试请求错误分类与停止再注入机制全解析【免费下载链接】oh-my-openagentOmO: Just type mass ulw keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent本篇技术指南聚焦 oh-my-openagent 中todo-continuation-enforcerBoulder 续跑机制的一次关键加固当会话收到提供商明确标记为不可重试的请求类错误典型如 compaction 后tool_use缺少tool_result的 400时插件必须停止再次注入续跑指令否则每次session.idle都会重建完全相同的失败请求把会话永久卡死。读完本文你将掌握该 Fail-Safe 的分类器实现、三处注入闸门的协作方式、状态机语义以及 Red-Green 测试与 hermetic 沙箱验证的完整方法。该机制位于 packages/omo-opencode/src/hooks/todo-continuation-enforcer/对应的变更证据文档为 .omo/evidence/20260805-continuation-stop-unrecoverable/README.md关联 issue #6605同时缓解 #6303、#6528。一、背景Boulder 续跑机制与注入-重试死循环风险1.1 todo-continuation-enforcer 是什么todo-continuation-enforcer是 oh-my-openagent 在 opencode 插件侧实现的Boulder 续跑钩子当会话中仍有未完成的 todo 时它会在session.idle事件上触发向会话注入一段CONTINUATION_PROMPT驱动 Agent 继续处理剩余任务。其工作流依据 AGENTS.mdsession.idle → 会话不是 prometheus/compaction/planDEFAULT_SKIP_AGENTS → 近期无 abort 信号ABORT_WINDOW_MS 3s → todos 仍有未完成项todo.ts → 无后台任务运行 → 冷却期已过CONTINUATION_COOLDOWN_MS 5s按失败次数指数退避 → 失败次数 上限MAX_CONSECUTIVE_FAILURES 5 → 未在轮次边界暂停continuationBlockReason → 启动 2 秒倒计时 toast → 注入 CONTINUATION_PROMPT注入动作本身由 continuation-injection.ts 的injectContinuation()完成先通过ctx.client.session.todo()拉取最新 todo 列表再调用dispatchInternalPromptmode: async、queueBehavior: defer把一段系统指令 剩余任务清单作为内部消息送入会话。1.2 死循环风险从何而来问题出在注入 → 失败 → 再注入的闭环上。默认状态下一次session.idle注入失败后consecutiveFailures计数递增但冷却退避只会在时间轴上拉长重试间隔见 constants.ts 中CONTINUATION_COOLDOWN_MS 5_000、MAX_CONSECUTIVE_FAILURES 5、FAILURE_RESET_WINDOW_MS 5 * 60 * 1000。如果错误根源是请求本身形状非法那么无论重试多少次、间隔多久重建出的请求都会以同样的方式被提供商拒绝——会话被永久卡在一个必败的循环里这正是本次变更要消灭的 wedge 场景。二、事故模型compaction 后的 tool_use/tool_result 400真实事故的失败请求形状被完整捕获并固化在测试中handler.test.ts 与 unrecoverable-request-error.test.ts 使用同一 payload{ name: APIError, data: { message: messages.2: tool_use ids were found without tool_result blocks immediately after: toolu_01PCXjcagoMAca32awicQHce. Each tool_use block must have a corresponding tool_result block in the next message., statusCode: 400, isRetryable: false } }这是 Anthropic 对compaction 请求携带了没有配套tool_result的tool_use块的拒绝。关键点在于该错误由消息序列的历史状态决定不随重试而改变。只要会话消息中仍存在孤立的tool_use续跑注入就必然再次触发同一个 400。因此降低重试频率毫无意义唯一的正确动作是识别并停止。三、Fail-Safe 设计一个分类器 三处注入闸门本次变更新增 unrecoverable-request-error.ts并让handler.ts、idle-event.ts、continuation-injection.ts、non-idle-events.ts、types.ts协同工作形成识别 → 落状态 → 三处拦截的完整闭环。3.1 分类器isUnrecoverableRequestErrorconst UNRECOVERABLE_REQUEST_STATUS_CODES new Set([400, 422]) export function isUnrecoverableRequestError(error: unknown): boolean { if (!error) { return false } const statusCode getRuntimeFallbackStatusCode(error) if (statusCode undefined || !UNRECOVERABLE_REQUEST_STATUS_CODES.has(statusCode)) { return false } return getRuntimeFallbackRetryableSignal(error) false }见 unrecoverable-request-error.ts判定逻辑是双重条件与状态码必须是请求形状类错误——400 Bad Request或422 Unprocessable ContentUNRECOVERABLE_REQUEST_STATUS_CODES集合提供商必须显式给出isRetryable: false信号。getRuntimeFallbackStatusCode与getRuntimeFallbackRetryableSignal来自oh-my-opencode/model-core的 runtime-fallback-error-shape.ts负责从各种错误形态Error实例、{ data: { statusCode } }、{ error: { code } }等中稳健地抽取状态码与可重试信号因此分类器能直接作用于 opencode 事件系统抛出的任意形态错误对象。3.2 闸门一handler.ts的session.error分支事件路由器 handler.ts 的createTodoContinuationHandler()对session.error事件按错误类型分流三路判定互斥错误类型状态落点行为MessageAbortedError/AbortErrorwasCancelled true、abortDetectedAt now重置失败/停滞计数取消倒计时token limit 错误isTokenLimitErrortokenLimitDetected true取消倒计时后续 idle 跳过避免上下文溢出恶化非可重试请求错误isUnrecoverableRequestErrorunrecoverableErrorDetected true取消倒计时后续 idle 跳过extractSessionErrorInfohandler.ts负责把事件里的error归一化成{ name, message }兼容字符串、Error实例和嵌套data.error/data.error等深层结构而真正传给分类器的是原始props?.error确保分类基于未失真的完整 payload。3.3 闸门二idle-event.ts的 idle 决策门在 idle-event.ts 的handleSessionIdle()中unrecoverableErrorDetected被放在决策链的最前列紧随 todo 完成/恢复/取消等基础门之后if (state.unrecoverableErrorDetected) { log([${HOOK_NAME}] Skipped: non-retryable request error detected, re-injecting would rebuild the same request, { sessionID }) return }这确保了后续的 todo 拉取、agent 解析、倒计时启动等昂贵步骤根本不会执行——错误一旦落旗续跑循环即刻冻结。3.4 闸门三continuation-injection.ts的注入失败捕获注入路径本身也必须兜底即使session.error事件丢失或延迟dispatchInternalPrompt抛出的异常也要被同一分类器识别。在 continuation-injection.ts 的 catch 分支中} catch (error) { if (isTokenLimitError(errorObj)) { injectionState.tokenLimitDetected true } else if (isUnrecoverableRequestError(error)) { injectionState.unrecoverableErrorDetected true log([${HOOK_NAME}] Non-retryable request error detected during injection, stopping continuation, { sessionID }) } }这是评审期间补充的两条路径之一失败注入被重抛进自身的 catch从而在错误发生的第一现场落旗而不是依赖后续事件。失败计数与lastInjectedAt照常更新但状态标志让下一轮 idle 直接短路。3.5 事件时序兜底non-idle-events.ts的延迟分类另一条评审补充路径处理了一个精妙的时序问题当unrecoverableErrorDetected已置位后如果到达一个不带 parts 的message.updatedroleuser事件立刻把它当作用户打断分类会误清旗标。现在的做法是延迟分类non-idle-events.tsconst shouldDeferClassification state ! undefined (hasAcceptedContinuationLifecycle(state) || state.unrecoverableErrorDetected true) if (parts undefined state shouldDeferClassification messageID) { state.pendingUserMessageID messageID // 分类推迟到 message.part.updated 时依据 split part 的真实内容判定 return }等到message.part.updated携带具体 part 到达时non-idle-events.ts才根据 part 是否属于合成/内部文本isSyntheticOrInternalOnlyTextParts决定是忽略、暂停还是清除错误旗标。同时注意语义细节真实用户打断会重置unrecoverableErrorDetected false见 non-idle-events.ts 的pauseForGenuineUserInterruption——即用户亲自介入后会话可以重新进入正常续跑生命周期错误旗标不是永久封禁。3.6 状态模型SessionState.unrecoverableErrorDetected新旗标被加入 types.ts 的SessionState与既有标志并列export interface SessionState { // ... tokenLimitDetected?: boolean unrecoverableErrorDetected?: boolean // ... }它本质上是一个每会话级别的熔断位置位后handleSessionIdle、injectContinuation、countdown 启动三处全部跳过只有真实用户打断或会话被删除session.deleted触发cleanup才会清除。四、分类器边界刻意窄化与负面用例设计文档明确指出分类器刻意保持窄化——只有请求形状状态码 AND 提供商显式isRetryable: false才计为不可恢复避免误伤可重试的瞬时错误。unrecoverable-request-error.test.ts 用 6 组用例把边界钉死输入状态码retryable 信号判定理由compactiontool_use/tool_result400真实 payload400false不可恢复目标场景{ statusCode: 422, isRetryable: false }422false不可恢复同属请求形状错误overloadedstatusCode 529529true可恢复状态码不在集合内bare400无 retryable 信号400无可恢复未获提供商确认的死路不封禁rate limitedstatusCode 429429false可恢复429 是限流重试在时间上有效undefined/null/ 字符串 /MessageAbortedError——可恢复空值或非请求类错误一律放行值得强调的是两个反直觉的负面用例bare 400 不封禁仅凭状态码无法区分请求形状永远非法与瞬时参数错误必须等提供商给出isRetryable: false才下结论429 isRetryable: false不封禁429 属于速率语义即使本次标记不可重试退避后仍可能成功不应被熔断。五、验证策略Red-Green 测试与 hermetic 沙箱证据文档 .omo/evidence/20260805-continuation-stop-unrecoverable/README.md 记录了完整的三段式验证。5.1 Failing-first证明旧代码确实有缺陷将旧版源码origin/dev检出与新增测试配对运行确保测试不是空转$ git checkout origin/dev -- .../todo-continuation-enforcer/{handler,idle-event,types,non-idle-events}.ts $ bun test packages/omo-opencode/src/hooks/todo-continuation-enforcer/handler.test.ts expect(state.unrecoverableErrorDetected).toBe(true) Expected: true Received: undefined 3 pass 1 fail失败语义旧代码中非可重试 400 到达后SessionState原封不动下一次session.idle仍会重新注入续跑指令并重建相同失败请求——正是要修复的 wedge。unrecoverable-request-error.test.ts未纳入该次运行因为其被测模块在origin/dev上尚不存在。产物为unit-red-before-fix.txt。5.2 Green新实现全绿bun test packages/omo-opencode/src/hooks/todo-continuation-enforcer 142 pass 0 fail 244 expect() calls (17 files) bun run typecheck - exit 0 bun build packages/omo-opencode/src/index.ts --outdir dist --target bun --format esm --external zod - exit 0评审期间补齐两条路径后完整套件为145 pass / 0 fail。全部用例含handler.test.ts中非可重试 400 标记会话不可恢复并取消倒计时可重试 529 不标记等均使用真实捕获的生产 payload 驱动真实的createTodoContinuationHandler事件路由器而非手造的替身对象。5.3 Hermetic 沙箱真实 opencode 端到端验证构建后的插件被加载进真实opencode1.18.13 运行沙箱%TEMP%\omo-qa-sandbox3隔离了全部用户态环境XDG_DATA_HOME/XDG_CONFIG_HOME/XDG_STATE_HOME/XDG_CACHE_HOMETMP/TEMP用户主目录USERPROFILE/HOME/HOMEDRIVE/HOMEPATH仅拷贝auth.json作为最小凭据观测结果REAL_DB_SESSIONS_BEFORE2864 SANDBOX_DB_PATH...\omo-qa-sandbox3\data\opencode\opencode.db OPENCODE_EXIT0 REAL_DB_SESSIONS_AFTER2864 ISOLATIONOK HOST_CONFIG_REFERENCES0插件端到端驱动了一个真实会话写入todowrite工具调用、WAITING文本留下两个 pending todos会话正常退出OPENCODE_EXIT0转录中没有任何[todo-continuation-enforcer]skip 行说明健康会话完全不受新分支影响host-config 泄漏扫描为 0真实~/.local/share/opencode/opencode.db的会话数前后不变2864 → 2864DB 隔离成立。产物为opencode-qa-plugin.log。5.4 为什么这套证据是充分的failing-first 证明新闸门在origin/dev上确实缺失回归被钉死测试不空洞判定逻辑由真实事件路由器 真实生产 payload 驱动hermetic 运行证明变更模块随构建插件发布、可在真实 opencode 中加载且不扰动健康会话——这正是本变更携带的回归风险点。六、已知局限与边界声明证据文档如实记录了三条未覆盖项理解它们有助于评估该 Fail-Safe 的适用前提沙箱内未复现一次真实的非可重试 400。触发它需要一个会拒绝请求形状的提供商免费沙箱模型返回的是 503 queue-full而产出原始 400 的生产卡死会话无法在全新 opencode 实例中重放。因此该分支的证明方式是用真实捕获的 payload 重放经过真实 handler而非在沙箱里让提供商真实拒绝一次。[todo-continuation-enforcer]idle 行不会出现在 CLIopencode run转录中——进程在 idle 驱动的续跑周期开始前就已退出所以健康会话无 skip 行的证据证明的是无回归而非 skip 分支实际触发。auth.json被复制进沙箱但未在任何产物中复现所有 artifact 均不含 token、API key 或 auth header凭证隔离与脱敏是验证流程的一部分。七、在本仓库中复现与延伸阅读复现本次变更的测试与构建# 运行 todo-continuation-enforcer 全量套件含 unrecoverable-request-error 与 handler 用例 bun test packages/omo-opencode/src/hooks/todo-continuation-enforcer # 单测分类器边界 bun test packages/omo-opencode/src/hooks/todo-continuation-enforcer/unrecoverable-request-error.test.ts # 类型检查与构建与证据文档中的 green 步骤一致 bun run typecheck bun build packages/omo-opencode/src/index.ts --outdir dist --target bun --format esm --external zod深入阅读建议按此顺序分类器本体unrecoverable-request-error.ts事件路由与错误分流handler.tsidle 决策门与注入兜底idle-event.ts、continuation-injection.ts时序兜底与用户打断语义non-idle-events.ts错误形态抽取工具runtime-fallback-error-shape.ts钩子整体定位与常量设计AGENTS.md、constants.ts变更证据全文.omo/evidence/20260805-continuation-stop-unrecoverable/README.md。总结本次 Fail-Safe 的核心工程结论是——对于提供商确认永远无法重试成功的请求形状错误正确的续跑策略不是再试一次而是识别并停下。通过400/422 显式isRetryable: false的窄化分类器、session.error/idle 决策门/注入 catch 三处协同落旗、partless 事件延迟分类的时序兜底以及 Red-Green 与 hermetic 沙箱的双重验证oh-my-openagent 让 todo 续跑机制在必败循环面前具备了可证明的熔断能力同时把误伤可重试瞬时错误的概率压到了最低。【免费下载链接】oh-my-openagentOmO: Just type mass ulw keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考