青雲的博客

Article

一个 Goal 为什么跑了 5 小时:从 463 次 update_goal 失败到 v0.2.46

· 21 分钟阅读

前言

2026 年 7 月 18 日,我在 Orca 里看到一个很矛盾的画面。

顶部的 Goal 明明是 active,已经运行了 5 小时 12 分,消耗 3.18 亿 Token。模型也知道任务应该结束,反复调用 update_goal("blocked"),工具却一直返回:

goal tools are only available while goal mode is active

Goal 明明处于 active,Goal 工具却说自己不在 Goal Mode。

底部费用已经到 18.69 美元,自动续跑还没有停。模型甚至开始在 reasoning 里解释:这一次和上一次似乎有一点不同,也许再读一遍完整 Goal 状态,就能找到新的退出方式。

第一眼看,这很像 DeepSeek 陷入死循环:模型没听懂停止条件,失败后还在机械重试。

最后查下来,模型当然有重试问题,但这不是根因。真正的问题在 Orca 的运行时:

模型看得见 update_goal,真正执行工具的 runtime 却拿不到这个 Goal 的 owner。

如果你还不了解 Orca,可以先看 orcaagent.dev。这篇不是功能介绍,而是一次从 session 取证、理解 thread-local、对照 Codex / Claude Code / Grok Build,到运行时重构、真实 API 验证和正式发布的完整复盘。

先解释一下 Goal Mode

Goal Mode 是 Orca 用来跑长任务的一套持久化机制。

普通对话完成一轮就可以停。Goal 不一样,它会保存目标、状态、Token、预算和累计时间。只要状态仍然是 active,TUI 就可以在一轮结束后自动发起下一轮 continuation,让 Agent 继续推进。

模型不能只在回复里说“我完成了”或者“我被阻塞了”。它必须调用控制面工具,把持久化状态更新为:

{ "status": "complete" }

或者:

{ "status": "blocked" }

update_goal 因此不是一个普通的数据查询工具。它决定这条长任务还要不要继续跑。

这也是为什么这次故障后果这么大:工作已经无法继续,但退出状态永远写不进去。

先别信截图里的“约 642 次”

截图是入口,不是证据源。

模型在 reasoning 里说工具大约失败了 642 次,但这是它根据上下文做的估计,不是 telemetry,不能拿来做事故统计。

我先从 ~/.orca/sessions/2026/07/17/ 找到对应的 JSONL,再把 tool events、持久化 turn id 和 goals_1.json 放在一起核对。

最后得到的数据是:

指标实际值
update_goal requested463
update_goal failed463
完成的 outer session120
持久化 turn id121
最终 Goal 状态active
Goal 记账 Token318,271,748
Goal 活跃时间18,750 秒
换算时长5 小时 12 分 30 秒
session 最终估算费用18.693824488 美元

这里还有一个容易混淆的口径。

Goal ledger 记录的是 318,271,748 Token。session 最后一条 usage 聚合则是 327,031,530 input、1,006,215 output、318,261,632 cache。它们来自不同的记账边界,不能因为数字接近就当成同一个指标。

到这一步,至少能排除两个误判:

  1. 不是“偶发失败几次”,而是 463 次请求全部失败。
  2. 不是 Goal 已经结束但 UI 没刷新,持久化状态最后确实还是 active

模型为什么不换个办法

模型的行为很荒谬,但站在它能看到的信息里,又有一定合理性。

它能在 tool schema 里看到 update_goal。Goal 指令也明确要求,满足完成或阻塞条件后必须调用这个工具。于是它判断工作无法继续,调用 update_goal("blocked")

工具返回失败。

普通 Agent 工具失败后,runtime 通常会把结果交还模型,让模型修参数、换工具或者重试。这是一种有价值的恢复机制。例如 JSON 写错、文件不存在、搜索为空,都不应该直接杀掉整轮任务。

问题是,这次失败不是参数错误,也不是临时网络问题,而是 runtime 根本没有 Goal 执行能力。

模型不知道这一点。它只看到:

我必须更新 Goal
-> update_goal 在工具列表里
-> 调用失败
-> runtime 允许我继续推理
-> 那就再检查一次条件,再调用一次

所以,只改 Prompt 或加一句“失败后不要重试”解决不了根因。只要系统一边向模型承诺工具存在,一边又把确定性的控制面故障包装成“可以继续恢复”,同类循环迟早还会发生。

顺着工具执行链往下追

接下来要回答两个问题:

  1. update_goal 为什么会出现在模型的工具列表里?
  2. 同一个请求为什么在执行时又认为 Goal Mode 不存在?

schema 这一侧其实是对的。

HostedTurnRequest::with_goal_tools(true)
-> ThreadTurnToolMode::Goal
-> AgentToolPolicyContext::goal_mode()
-> provider 暴露 get_goal / create_goal / update_goal

真正丢状态的是执行侧。

ThreadTurnToolMode::Goal 已经进入 step snapshot,但在构造 runtime tool request 时没有继续传播。路由器拿到 update_goal 后,不知道这是一个 Goal 控制面操作,于是把它当成普通工具执行:

ThreadTurnToolMode::Goal
-> provider 看见 Goal tool schema
-> runtime execution request 丢失 Goal capability
-> update_goal 被分类成普通工具
-> RuntimeToolCallRuntime::execute_normal
-> 启动 orca-normal-tool OS thread
-> 新线程里的 GOAL_HANDLER 为空
-> 返回 failed tool result
-> 模型继续恢复
-> outer turn 仍可能 success
-> Goal 继续 active
-> TUI 再提交下一轮 continuation

这条链同时解释了三个互相矛盾的现象:

  • 模型能看见工具,因为 provider policy 仍然是 Goal Mode。
  • 工具说 Goal Mode 不存在,因为执行线程里的 handler 是空的。
  • TUI 一直自动续跑,因为 outer turn 还能成功结束,持久化 Goal 又始终是 active

Orca 原本还有一个低进展 stall detector,但它保护不了这条路径。它识别的是连续多轮成功、每轮新增少于 500 Token 的低进展状态。这次模型每轮都做了大量 reasoning 和工具调用,Token 消耗远高于阈值。

一个确定性的控制面失败,被藏在了“高消耗但 outer success”的外壳里。

thread-local 到底是什么

旧实现里,Goal 工具通过 Rust 的 thread_local! 找 handler:

thread_local! {
    static GOAL_HANDLER: RefCell<Option<GoalHandler>> = RefCell::new(None);
}

thread-local 的含义很直接:每个 OS thread 都有一份独立变量。

它适合保存真正和线程绑定的东西,例如线程缓存、渲染临时状态,或者只在当前线程内有意义的 telemetry。它不等于:

  • session-local
  • async task-local
  • actor-local
  • request-local

旧路径实际变成了:

TUI thread
  GOAL_HANDLER = Some(...)

orca-normal-tool thread
  GOAL_HANDLER = None

如果 handler 安装在线程 A,工具在线程 B 执行,线程 B 看到的是自己的 TLS slot。std::thread::spawnspawn_blocking、worker pool 和 async task 迁移,都不会替你复制 thread-local 状态。

提交历史也印证了这一点。

  • 7ad993322 在把 TUI 执行迁进 RuntimeHost 时,删掉了最后一个生产环境的 with_goal_handler 安装点。
  • 9d6d4d432 又把普通工具的 orca-normal-tool 线程边界做得更明确。

因此,即使把 with_goal_handler 补回 TUI 外层,也只是把 callback 放回 TUI 所在线程。真正执行 update_goal 的 worker 仍然是另一条 OS thread,状态还是过不去。

这也是为什么最后没有选择“补回一行 handler 安装”这种修法。它能让某些测试变绿,却没有修正 ownership。

这是 Goal 的问题,还是系统性问题

事故发生在 Goal Mode,但缺陷类型是系统性的。

工具可见性和执行能力分叉

工具是否对模型可见,和 runtime 是否真的能执行,来自两条不同的数据路径。两条路径只要有一条少传一个字段,系统就会向模型暴露一项不存在的能力。

更可靠的不变量应该是:

schema 可见,意味着当前 runtime 一定持有可执行、可验证的 context。

控制面 owner 依赖执行现场

Goal 不是一次性函数调用。它横跨多个 turn,持有 session id、持久化状态、预算、计时和退出条件。

这种控制面能力不应该靠“当前线程里刚好装了一个 callback”来找 owner。owner 应该由 runtime 显式持有,并沿调用链传进去。

控制面失败继承了普通工具的恢复语义

文件没找到,可以让模型换路径。GoalStore 不存在、session owner 丢失,则说明系统已经无法兑现自己的运行协议。

两种失败都叫 failed,后续动作却完全不同。

持久化状态和易失上下文没有统一清理路径

Goal 已完成、暂停、阻塞、超预算或 stall 后,持久化状态会变化,prompt 里的 Goal block 也应该一起清掉。否则模型下一轮还可能读到过期控制信息。

所以答案是:这是一个 Goal-specific 事故,也是一个通用的 runtime ownership 缺陷。任何 context-scoped tool,只要 schema 和执行能力分开推导,都可能复现同类问题。

Codex、Claude Code、Grok Build 怎么做

我又对照了本地的 Codex、Claude Code 和 Grok Build 源码。

三套实现 API 不一样,但 ownership 的方向很一致:上下文跟着调用走,不跟着线程身份走。

实现工具展示工具执行对 Orca 的启发
Codexbuild_tool_specs_and_registry 从同一组 planned runtimes 生成 model-visible specs 和 ToolRegistryToolCallRuntime 显式持有 SessionStepContext、cancellation 和 trackerschema 与 registry 来自同一个能力规划结果
Claude CodeToolUseContext.options.tools 决定当前工具集合每个 Tool.call 都显式接收 ToolUseContext,里面有 AbortController、app/session state 和交互能力调用 context 是参数,不是执行现场里临时找回的全局状态
Grok BuildListToolsContext 负责 listing 和动态描述ToolCallContext 通过 typed extensions 携带 SessionContextCancellation、cwd 等能力展示和执行可以分型,但 capability 必须类型化传递,执行流也必须有终态

这里真正值得参考的不是类名,而是共同原则:

  1. 工具列表和可执行 registry 不能各走各的。
  2. session、cwd、cancellation、权限这类 context 应该显式进入 invocation。
  3. runtime 必须区分模型可修复的工具错误和必须结束当前 turn 的控制面错误。
  4. 不要用 OS thread identity 代替 session 或 actor ownership。

最后的修复:让 Goal 回到 runtime 控制面

v0.2.46 没有继续修补 TLS,而是把 Goal 工具改成 runtime-owned special dispatch。

删除隐式 owner

先删除旧路径:

  • thread_local! GOAL_HANDLER
  • GoalHandler callback type
  • with_goal_handler
  • TLS 安装测试
  • Goal 工具进入 normal worker 的生产路径

orca-tools/update_goal.rs 只保留参数解析和面向模型的结果格式化,不再负责寻找持久化 owner。

显式传播 Goal capability

ThreadTurnToolMode::Goal 继续作为唯一的 turn capability,但现在它不仅决定 provider schema,也会沿 step snapshot、tool request、invocation context 一直传播成 goal_mode

这样,“模型能看见 Goal 工具”和“runtime 能执行 Goal 工具”由同一个能力来源驱动。

在 normal worker 之前分流

get_goalcreate_goalupdate_goal 会在 readonly batching 和 normal-worker execution 之前,由 RuntimeToolRouter 分流到 special dispatch。

执行器直接使用:

  • 持久化 session id
  • live extension stores
  • GoalStore

Goal 工具不再进入 orca-normal-tool,也不再依赖执行线程里有没有某个 callback。

区分 ContinueModel 和 StopTurn

这是本次修复里另一个关键变化。

下面这些错误仍然允许模型恢复:

  • JSON 参数不合法
  • status transition 不合法
  • create 请求尚未满足条件
  • 普通工具失败

它们返回 ContinueModel

下面这些错误则意味着 runtime 已经无法兑现 Goal 能力:

  • 缺少持久化 session
  • 缺少 live runtime extension context
  • GoalStore I/O 失败

它们在记录一次 tool result 后返回 StopTurn。系统不再让模型对一个确定性的基础设施故障无限重试。

控制面失败必须进入状态机

如果一次被追踪的 Goal generation 失败,RuntimeHost 会原子地执行:

active -> stalled

它只改 active,不会覆盖用户已经设置的 paused,也不会覆盖并发写入的 blockedcompletebudget_limited

与此同时,pause、clear、complete、blocked、budget limit、runtime stall 和普通 turn 都会清理易失的 Goal prompt context。

到这里,错误不只是“被打印出来”,而是真正进入了生命周期。

怎么证明这次真的修好了

这类问题只跑一个单元测试不够,因为它横跨 provider schema、runtime dispatch、session persistence、TUI continuation 和真实模型行为。

验证分成了三层。

先写失败测试

先补 RED case,覆盖:

  • Goal tool 在 hosted runtime 中能更新真实持久化 Goal。
  • 缺少 runtime context 时,当前 turn 必须失败,且只记录一次 tool result。
  • 没有持久化 session 时,RuntimeHost 在 provider 看见 schema 之前就拒绝请求。
  • stall_if_active 只能把 active 改成 stalled,不能覆盖其他状态。
  • Goal tool 不得进入 readonly batch 或 normal worker。

再跑完整回归

除了 Goal/runtime/TUI 的 focused tests,还跑了完整串行 workspace tests、workspace Clippy、release helper、npm staging、站点构建、SEO 和 diff 检查。

这里特意保留了普通工具原有的恢复语义。不能为了阻止 Goal 死循环,就把 Bash、MCP、文件工具的普通失败全部改成终止 turn。

最后用真实 DeepSeek 验证

最后增加了一个真实计费的 Goal Mode release case。

它要求 DeepSeek 先执行一个非 Goal 工具,再调用一次 update_goal,然后验证持久化状态和 continuation:

Goal Mode real API e2e verified:
status=complete
non_goal_tools=1
update_goal_calls=1
continuations=0

这四行比“模型回答看起来正常”更重要。

它证明 Goal 工具不是只在 mock 里可调用;状态真的写进了 store;TUI 也不会在完成后再自动提交下一轮。

核心修复提交是 537d14e51,真实 API gate 是 8c6323fce,事故报告是 64ad58070,随后由 8dcffc0dd 准备 v0.2.46 发布。

发布时又遇到一个 Linux 竞态

第一次 Release run 29625028532 还是失败了。

这次失败和 Goal 修复无关。失败的是:

server_mode_interrupt_cancels_active_bash_tool_wait_and_accepts_next_turn

测试原来的时序是:

收到 tool_requested
-> 立即发送 interrupt
-> 断言 invocationStarted == "yes"

这里把两个不同状态混在了一起。

tool_requested 只能证明协议层已经收到工具请求,不代表 bash worker 已经完成 admission 并真正启动。在 Linux CI 上,interrupt 可能先到,worker 还没开始执行,于是持久化结果合理地变成:

invocationStarted: "no"

测试却强制要求 yes,所以 133 个测试通过,1 个测试失败。

修法不是延长 sleep,而是给 bash 命令加一个真实 marker:只有 marker 文件出现,才说明 worker 已经进入 invocation,然后再发送 interrupt。

这个测试同步修复提交是 df552628b

它和 Goal 事故其实是同一类提醒:不要拿一个“看起来差不多”的外部事件,替代系统真正的状态边界。

最终发布结果

第二次 Release run 29625433072 成功,Pages run 29625016130 也成功。

最终公开结果包括:

公开验证器的最终结果是:

GitHub Release verified: https://github.com/echoVic/blade-deepseek/releases/tag/v0.2.46
npm package verified: @blade-ai/[email protected]
npm exec smoke verified: orca 0.2.46

最后

这次事故留下了几个很具体的判断。

第一,工具出现在 schema 里,不代表系统真的拥有这项能力。工具可见性和执行能力必须来自同一个 runtime context。

第二,thread-local 不是错,但它只能表达 thread ownership。拿它承载 session、actor 或跨 turn 控制状态,边界迟早会错。

第三,失败不能只有一个 failed。模型可修复的参数错误、runtime 必须停机的控制面错误,需要不同的后续语义。

第四,自动 continuation 不能只看 outer turn 是否 success。持久化控制状态、终止原因和 failure disposition 必须一起决定下一轮是否还能启动。

第五,测试也需要状态机意识。requestedadmittedstartedcancelled 是四个状态,不能因为本地机器跑得快,就把它们当成同一时刻。

这次真正修掉的,不是一个空的 GOAL_HANDLER

而是把 Goal 从一个依赖执行现场的普通工具,重新变成 runtime 显式拥有的控制面操作。前者只能让错误少出现,后者才能让错误发生时,系统知道自己应该停下来。

Keep Reading

相关文章

评论