Article
一个 Goal 为什么跑了 5 小时:从 463 次 update_goal 失败到 v0.2.46
先看到的画面
2026 年 7 月 18 日,Orca 里有个很拧巴的画面。
顶部 Goal 仍是 active,已经跑了 5 小时 12 分,Token 记账到大约 3.18 亿。模型自己也觉得该结束了,反复调 update_goal("blocked"),返回却一直是:
goal tools are only available while goal mode is active
Goal 明明显示 active,工具却说自己不在 Goal Mode。底部费用滚到 18.69 美元,自动续跑还没停。模型甚至开始在 reasoning 里自我安慰:这一次好像跟上一次有点不一样,再读一遍完整 Goal 状态,说不定能找到出口。
我第一反应是:DeepSeek 又在死循环里空转,停止条件没听懂。
后来发现模型确实会机械重试,但那不是根因。根因在 Orca:update_goal 出现在工具列表里,真正干活的 runtime 却拿不到这个 Goal 的 owner。
如果你还不了解 Orca,可以先看 orcaagent.dev。下面按取证顺序写,不展开产品介绍。
Goal Mode 在系统里干什么
Goal Mode 是 Orca 跑长任务用的持久化机制。普通对话一轮结束就可以停;Goal 会把目标、状态、Token、预算和累计时间存下来。只要状态还是 active,TUI 就会在一轮结束后自动开下一轮 continuation。
模型不能只在回复里写「我完成了」或「我被卡住了」。它必须调控制面工具,把持久化状态改成:
{ "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 requested | 463 |
update_goal failed | 463 |
| 完成的 outer session | 120 |
| 持久化 turn id | 121 |
| 最终 Goal 状态 | active |
| Goal 记账 Token | 318,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。数字接近,边界不同,不能当成同一个指标。
至少能排除两件事:不是偶发失败几次,而是 463 次全挂;不是 Goal 已经结束只是 UI 没刷,持久化状态最后确实还是 active。
模型为什么死磕
站在模型能看到的信息里,它的行为其实说得通。
tool schema 里有 update_goal。Goal 指令也写着:完成或阻塞时必须调这个工具。它判断干不下去了,就调 update_goal("blocked")。工具失败后,runtime 按普通 Agent 习惯把结果交还给模型——JSON 写错、文件不存在、搜索为空,本来就不该直接杀整轮。
问题是这次失败既不是参数错,也不是网络抖一下,而是 runtime 根本没有 Goal 执行能力。模型看不到这层,它只看到:
我必须更新 Goal
-> update_goal 在工具列表里
-> 调用失败
-> runtime 允许我继续推理
-> 那就再检查一次条件,再调用一次
所以单改 Prompt、加一句「失败后别重试」没用。系统一边承诺工具存在,一边把确定性控制面故障包装成可恢复错误,同类循环还会再来。
顺着执行链往下追
两个问题:
update_goal为什么会出现在模型工具列表里?- 同一个请求执行时为什么又说 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 后不知道这是控制面操作,当普通工具跑:
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,所以模型看得见工具;执行线程 handler 为空,所以工具说 Goal Mode 不存在;outer turn 还能成功结束、持久化 Goal 还是 active,所以 TUI 一直续跑。
Orca 本来有个低进展 stall detector,这次也救不了。它盯的是连续多轮成功、每轮新增少于 500 Token。这次模型每轮都在大量 reasoning 和调工具,Token 远高于阈值。一个确定性的控制面失败,被藏在了「高消耗但 outer success」的壳子里。
thread-local 在这里踩了什么
旧实现用 Rust thread_local! 找 Goal handler:
thread_local! {
static GOAL_HANDLER: RefCell<Option<GoalHandler>> = RefCell::new(None);
}
thread-local 就是每个 OS thread 一份。它适合线程缓存、渲染临时状态这类真跟线程绑的东西,不等于 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::spawn、spawn_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。
所以最后没走「补回一行安装」的修法。它能让某些测试变绿,ownership 还是错的。
这是 Goal 特例,还是系统病
事故出在 Goal Mode,病症类型却是通用的。
工具可见性和执行能力分叉。 对模型展示什么工具、runtime 能不能执行,走的是两条路径。少传一个字段,就会向模型承诺一项实际不存在的能力。更稳的不变量应该是:schema 可见,就说明当前 runtime 一定握着可执行、可验证的 context。
控制面 owner 绑在执行现场。 Goal 横跨多 turn,带着 session id、持久化状态、预算、计时和退出条件。这种能力不该靠「当前线程刚好有个 callback」找主人,应该由 runtime 显式持有并沿调用链传下去。
控制面失败复用了普通工具的恢复语义。 文件找不到,可以让模型换路径;GoalStore 不存在、session owner 丢失,说明系统已经兑现不了自己的协议。两种失败如果都叫 failed,后续动作却不该一样。
持久化状态和易失上下文没有统一清理。 Goal 完成、暂停、阻塞、超预算或 stall 之后,prompt 里的 Goal block 也该一起清。否则下一轮还可能读到过期控制信息。
所以这是 Goal 事故,也是 runtime ownership 缺陷。任何 context-scoped tool,只要 schema 和执行能力分开推导,都可能复现。
别人怎么做
我对照了本地 Codex、Claude Code 和 Grok Build 源码。API 不一样,ownership 方向差不多:上下文跟着调用走,不跟着线程身份走。
| 实现 | 工具展示 | 工具执行 | 对 Orca 的启发 |
|---|---|---|---|
| Codex | build_tool_specs_and_registry 从同一组 planned runtimes 生成 model-visible specs 和 ToolRegistry | ToolCallRuntime 显式持有 Session、StepContext、cancellation 和 tracker | schema 与 registry 来自同一个能力规划结果 |
| Claude Code | ToolUseContext.options.tools 决定当前工具集合 | 每个 Tool.call 都显式接收 ToolUseContext,里面有 AbortController、app/session state 和交互能力 | 调用 context 是参数,不是执行现场里临时找回的全局状态 |
| Grok Build | ListToolsContext 负责 listing 和动态描述 | ToolCallContext 通过 typed extensions 携带 SessionContext、Cancellation、cwd 等能力 | 展示和执行可以分型,但 capability 必须类型化传递,执行流也必须有终态 |
值得抄的是原则,不是类名:
- 工具列表和可执行 registry 不能各走各的。
- session、cwd、cancellation、权限这类 context 要显式进 invocation。
- 模型可修的工具错误,和控制面必须停 turn 的错误,要分开。
- 别用 OS thread identity 冒充 session / actor ownership。
v0.2.46 怎么改
没有继续补 TLS,而是把 Goal 工具改成 runtime-owned special dispatch。
删掉隐式 owner
去掉 thread_local! GOAL_HANDLER、GoalHandler callback、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。「看得见」和「能执行」共用同一来源。
在 normal worker 之前分流
get_goal / create_goal / update_goal 在 readonly batching 和 normal-worker 之前,由 RuntimeToolRouter 走 special dispatch。执行器直接用持久化 session id、live extension stores 和 GoalStore,不再进 orca-normal-tool。
ContinueModel vs StopTurn
参数不合法、status 转换不合法、create 条件未满足、普通工具失败——仍返回 ContinueModel,让模型修。
缺少持久化 session、缺少 live runtime extension、GoalStore I/O 失败——记录一次 tool result 后返回 StopTurn。基础设施已经坏了,就别让模型对同一个确定性错误刷几百次。
控制面失败要进状态机
被追踪的 Goal generation 失败时,RuntimeHost 原子地做 active -> stalled:只动 active,不覆盖用户已设的 paused,也不覆盖并发写入的 blocked / complete / budget_limited。pause、clear、complete、blocked、budget limit、runtime stall 和普通 turn,都会清理易失 Goal prompt context。
错误不再只是打印一行,而是进入生命周期。
怎么证明修好了
这类问题跨 provider schema、runtime dispatch、session 持久化、TUI continuation 和真实模型行为,一个单测不够。
先补 RED case:hosted runtime 里 Goal tool 能更新真实持久化 Goal;缺 runtime context 时当前 turn 必须失败且只记一次 tool result;没有持久化 session 时,RuntimeHost 在 provider 看见 schema 之前就拒;stall_if_active 只能 active→stalled;Goal tool 不得进 readonly batch 或 normal worker。
再跑完整串行 workspace tests、Clippy、release helper、npm staging、站点构建、SEO 和 diff。普通工具的恢复语义刻意保留——不能为了掐 Goal 死循环,把 Bash / MCP / 文件工具的普通失败全改成终止 turn。
最后加真实计费的 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
这四行比「模型回答看起来正常」有用:工具不只在 mock 里可调,状态真写进 store,完成后 TUI 也不会再自动续一轮。
相关提交:核心修复 537d14e51,真实 API gate 8c6323fce,事故报告 64ad58070,发布准备 8dcffc0dd。
发布时 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 文件出现才算进入 invocation,然后再 interrupt。提交 df552628b。
和 Goal 事故是同一类提醒:别拿「看起来差不多」的外部事件,顶替系统里真实的状态边界。
发布结果
第二次 Release run 29625433072 和 Pages run 29625016130 过了。公开结果:
- GitHub Release v0.2.46
- 13 个 GitHub Release assets
- npm
@blade-ai/[email protected] - 4 个平台安装变体(darwin / linux × arm64 / x64)
npm execsmoke 输出orca 0.2.46
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 里,不等于系统拥有这项能力;可见性和执行能力必须同源。thread-local 本身没错,但只能表达 thread ownership,拿它扛 session / actor / 跨 turn 控制状态,早晚漏。失败语义不能只有一个 failed——模型可修的参数错误,和 runtime 必须停机的控制面错误,后续动作不一样。自动 continuation 也不能只看 outer turn 是否 success;持久化控制状态、终止原因和 failure disposition 要一起参与决策。测试侧也是:requested、admitted、started、cancelled 是四个状态,本地机器跑得快,不代表它们是同一时刻。
空的 GOAL_HANDLER 是症状。更实质的改动是:Goal 不再依赖执行现场里碰巧存在的 callback,而是 runtime 显式拥有的控制面操作。前者让错误少出现一会儿;后者让错误出现时,系统知道该停。
后续还有一轮:修完工具能执行之后,固定 64 轮上限又把正常长任务误判成无进展。那是下一篇的故事:Goal 为什么又停了。
Keep Reading