青雲的博客
拆开 Codex 第五部:会话怎样保存、恢复并继续 第 29 章

Goal 为什么必须活在 Turn 之上

从显式 Goal mutation、GoalStore、GoalRuntimeHandle、turn 与 idle accounting 一路追到 idle continuation 和 ThreadGoalUpdated,划清 durable objective 与单次 Turn 的所有权。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

第 28 章已经把 Memory 的跨 thread 提取与 Goal 的同 thread continuation 分开。沿着后者继续,真正需要解释的是:为什么 Goal 必须活在 Turn 之上?

一个 turn 是同一 TurnContext 管理的一次用户可见任务周期,内部可以包含多次模型采样和工具循环;长期目标可能跨过多个 turn、一次进程退出和一次 thread resume。把 objective 塞进某个 turn 的 prompt,不足以回答三个工程问题:重启后从哪里恢复,最终 token 归谁结算,thread idle 后谁有权再启动一次 turn。

固定版本给出的主链是:

flowchart LR
  accTitle: Goal 从显式变更到 idle continuation 的所有权链
  accDescr: Thread lifecycle 先为 live thread 建立进程内 GoalRuntimeHandle 与 accounting state;显式用户或系统变更与模型 Goal tool 分别经过 GoalService 或 GoalToolExecutor 写入 GoalStore,resume 只把 durable active goal id 恢复到 accounting,turn 和 idle accounting 再更新 durable progress;thread idle 时还要重新检查 durable active 状态与启动资格。
  THREAD["live thread lifecycle"] --> HANDLE["GoalRuntimeHandle"]
  HANDLE --> ACCOUNTING["GoalAccountingState"]
  EXPLICIT["explicit user / system mutation"] --> SERVICE["GoalService"]
  TOOLCALL["create_goal / update_goal"] --> EXECUTOR["GoalToolExecutor"]
  SERVICE --> STORE["GoalStore SQLite"]
  EXECUTOR --> STORE
  STORE -->|"resume: active goal id only"| ACCOUNTING
  TURN["non-Plan turn usage"] --> ACCOUNTING
  IDLE["idle wall-clock"] --> ACCOUNTING
  ACCOUNTING --> STORE
  STORE -->|"write returns"| CALLER["tool or app-server caller"]
  CALLER --> EVENT["ThreadGoalUpdated projection"]
  STORE --> ELIGIBLE{"durable status == active?"}
  ELIGIBLE -->|"yes + visible tools + live idle thread"| START["try_start_turn_if_idle"]
  START --> TURN

这条链至少分成四类 owner:

状态或动作owner能保证什么不能保证什么
objective、status、token budget 与 token/time usageGoalStore 的 durable row进程退出后仍能按 thread 读取不代表 runtime 正在计时,也不代表下一 turn 一定启动
runtime 引用与开关、当前 goal id、turn/idle 起点GoalRuntimeHandle / GoalAccountingState同一进程内把增量归给当前 Goal不保存 objective、budget 或完整 durable row
external mutation 与 stop/idle-start 的顺序goal_state_lock防止外部 set/clear 穿过 stop 或 idle read/start不包住 model tool mutation 或 resume restore
某段 progress 是否已经 flushprogress_accounting_lock防止并发路径重复结算同一增量不校验 SQLite 中仍是不是同一个 Goal
durable row identityexpected_goal_id指定写回只命中预期 Goal它是可选参数,不能外推为每次写都带 identity guard

Goal 是显式 durable state,不是自动任务理解

状态机比“进行中 / 已结束”多一层

ThreadGoal 以 thread 为作用域保存 goal_idobjective、status、可选 token_budget、累计 tokens_used / time_used_seconds 与时间戳。这里没有 time budget;time 字段记录已经用掉的 wall-clock seconds。状态枚举 ThreadGoalStatus 在固定版本有六种取值:activepausedblockedusage_limitedbudget_limitedcomplete

最容易写错的是 blocked。它会停止 continuation,但 is_terminal() 只把 budget_limitedcomplete 判为 terminal。于是 blocked 既不是“仍可自动推进”,也不是“已经完成”;它保留的是一个需要用户或外部条件解除的 durable 停点。

这里不能直接把 is_terminal() 当作模型侧 create_goal 的替换条件。GoalToolExecutor::handle_create 调用的 insert_thread_goal 更窄:冲突时只有旧 row 为 complete 才允许新 Goal 覆盖。budget_limited 虽然在枚举 helper 中属于 terminal,仍不能被这个工具替换;activepausedblockedusage_limited 当然也不行。app-facing GoalService::set_thread_goal 走另一条路径,后文单独说明。

status自动续跑is_terminal()典型 owner
active仍需通过 runtime eligibilityfalse用户、系统或 tool 创建;runtime 保持 active
pausedfalse用户 / 系统 pause-resume 路径
blockedfalsemodel tool 或 runtime error stop
usage_limitedfalseruntime 用量限制路径
budget_limitedtrueaccounting 达到目标预算
completetruemodel tool 或外部服务确认完成

这张表也说明 Goal 不是 planner。源码没有从 History 生成任务图,也没有把 plan item 自动升级为 Goal。它只保存一条显式 objective 和它的 durable lifecycle。

本节源码依据(4 处)

update_goal 的最终结算不能被 generic finish 再收一次

模型调用 update_goal 时,tool executor 已经把这次 tool turn 的最终 progress 交给 Goal accounting。通用 tool-finish adapter 因而显式排除 update_goal;否则同一份 input/output delta 会先在专用 Goal 路径写入一次,再被 generic path 追加一次。

这个排除只针对 accounting,不代表 update_goal 不会产生事件。status mutation 仍会写 durable row,并投影 active 到 blocked 或 complete 的 ThreadGoalUpdated。事件与 token 结算是两个观察面,不能用“看到了事件”推断“又结算了一次”。

本节源码依据(2 处)

Idle time 是 wall-clock progress,不是另一个 turn

当 thread 没有活跃 turn、Goal 仍为 active 且 runtime handle 还活着时,accounting 会从 idle 起点到 flush 时刻累计 wall-clock seconds。这个值与 token usage 共用 durable progress,但不应该伪装成一个空的 model turn;它回答的是“目标在等待期间持续了多久”,不是“模型又生成了一次回答”。

idle flush 同样带 expected identity,并在 progress_accounting_lock 下只提交未结算的 delta。若 thread 已 detach、Goal 已 blocked/terminal,剩余 idle 时间会停止归集,不会跨状态边界记到后一个 Goal。

Resume 只把 durable active Goal 重新接回 idle accounting

恢复顺序不是重放旧 turn

resume 先恢复 thread 和 rollout。thread-start lifecycle 已经为这条 live thread 建好并注册 GoalRuntimeHandleGoalAccountingState;随后 resume hook 复用这个 handle 读取当前 durable row。若 Goal extension 启用且 row 是 active,restore_after_resume 只把 goal id 标成新的 idle accounting baseline;旧进程里的 handle、锁和未 flush 的局部计时不会被重放。若 row 是 paused、blocked、usage_limited、budget_limited、complete 或不存在,它就清空 process-local active marker。

这条 restore 没有取得 goal_state_lock,也不会把 objective、budget 或累计 usage 复制进 handle。它只恢复“当前哪个 durable goal 可继续计时”这一个 accounting identity;真正启动下一 turn 的 continue_if_idle 才在 goal_state_lock 下重新读取 row,并检查 tools、live manager/thread 与 try_start_turn_if_idle

这与 Memory 的 resume 边界不同:Memory 读取的是文件摘要和按需 citation,Goal resume 读取的是本 thread 的一行 durable state。两者都不把旧进程的局部对象直接搬回新进程。

本节源码依据(3 处)

Runtime handle 附着 live thread,active Goal 只激活 accounting

GoalRuntimeHandle 是进程内的执行挂点

Goal extension 的 thread-start contributor 不先查询 Goal row。它为每条经过该 lifecycle 的 live thread 取得或创建 GoalRuntimeHandleGoalAccountingState,写入当前 enabled/tool-availability flags,并把 handle 注册到 GoalService;所以没有 Goal、Goal 非 active,甚至 Goal feature 当前关闭,都不等于 handle 不存在。是否显示 model tools、是否把 accounting 标成 active,是后续独立判断。

handle 本身保存 thread id、state DB、event/metrics/analytics、weak thread manager、accounting state、enabled/tool flags 与 goal_state_lock,不保存 objective、budget 或完整 Goal row。当前 goal id、turn token baseline 与 idle time baseline 在 GoalAccountingState 中;objective、budget 和累计 usage 仍以 SQLite row 为准。它的作用是把 live thread 的 turn/idle 事件路由到正确的 durable row,而不是成为第二份可独立写入的 Goal。

因此“active row 存在”与“当前 thread 会继续”是两件事。还必须有 goal extension 的可见 tool、一个仍然存活的 manager/thread,以及 idle path 最后一次 try_start_turn_if_idle 接受这次启动。

本节源码依据(3 处)

两把锁解决两个不同问题

源码里 goal_state_lockprogress_accounting_lock 的职责不能合并成“Goal 有一把大锁”:

  • goal_state_lock 只覆盖明确接入它的路径:app-facing GoalService set/clear、runtime 的 error/usage stop,以及 continue_if_idle 的 durable read/start window。restore_after_resume 和模型侧 create_goal / update_goal 都不取得这把锁,不能把它写成所有 Goal mutation 的总序列化器。
  • progress_accounting_lock 保护 token/time progress 的取样、去重和 flush。它避免 turn finish、idle tick 与最终 tool finish 同时看到同一 delta 后重复累加。

两把锁都不能替代 expected_goal_id。锁只约束当前进程的先后,expected id 才把一次条件写回与某个 durable row 绑定;重启、旧 handle 或另一个进程都不能靠进程锁获得这层身份保证。

本节源码依据(3 处)

Accounting 只收真正属于 Goal 的增量

Token 先扣 cached input,再决定是否写回

Goal accounting 的 token 公式是:

input_tokens - cached_input_tokens + output_tokens

这不是 Responses 原始 usage 的直接复制。cached input 已经被计过,就从本次 input 中扣除;如果扣减结果为负,runtime 按实现约束归零而不是写入负 usage。一次 turn 的最终 delta 还要带上它对应的 goal id,避免 idle flush 把旧 handle 的进度算到新 Goal。

Plan turn 不进入 Goal token accounting。它用于调整执行计划和 UI 状态,不是对 objective 产生模型工作进度的普通 turn;把它计入会让 budget 看起来比真实消耗更快用尽。

本节源码依据(3 处)

模型只能在明确授权后创建

Goal tool 的 contract 把“可调用”与“可自行决定”分开。create_goal 只有在用户、system 或 developer 明确要求创建或开始一个 Goal 时才允许调用;普通“帮我修这个 bug”不能由模型擅自升级成持久 Goal。update_goal 对模型只暴露 completeblocked 两种目标状态。

pause、resume、usage limit 和 budget limit 没有因此消失。它们属于用户、系统或 runtime 路径,不属于模型 tool 的自由状态转换。tool description 还要求 blocked 至少基于同一个阻塞条件连续出现三次 Goal continuation turn;但 Rust executor 并没有保存并递增一份“三次计数器”。这是一条模型侧行为合同,不能写成 runtime 已机械证明的 invariant。

本节源码依据(2 处)

模型工具与 GoalService 不是同一个写入口

create、pause、resume 和 status update 不是同一个入口

模型 create_goal 不经过 GoalService。它在工具 handler 内生成请求并直接调用 insert_thread_goal,所以 complete-only replacement 是这条 model-facing 路径的合同。

App-facing GoalService::set_thread_goal 处理的是外部显式 mutation。已有 row 时,它读取当前 goal_id,用 update_thread_goal 原地更新同一 Goal,并带上 expected identity;没有 row 时才调用 replace_thread_goal 创建新 Goal。replace_thread_goal 的 SQL 没有 complete-only 条件,但 service 只在刚刚读到“没有 row”的分支调用它。把这条 set 路径写成模型 create_goal 的同义词,会同时写错 identity 和 conflict 语义。

这两条入口都没有 planner 语义:输入只有 objective、status 和 token budget 等字段,不接受 directed acyclic graph(DAG,有向无环图)、子任务列表或自动推断的历史摘要。thread_goalsthread_id 为主键,所以一个 thread 只有一条 Goal row。模型在旧状态为 complete 时创建新 Goal,会改写这条 row 的 goal id、objective、status、预算、usage 和时间戳;外部 set 已有 Goal 时则保留 goal id 做原地更新。这里没有 Goal 历史表,被替换的旧 row 不会继续作为历史记录保留。

expected_goal_id 也要留出准确边界。外部 GoalService 更新已有 row、runtime error stop 和 accounting writeback 会传它;模型 update_goal 直接调用 store,status 写入传 None。因此不能概括成“每次 Goal 写都做 identity compare”。真正的保证来自 caller 选择了哪个 store 方法,以及那条条件 SQL 是否包含 expected id。

本节源码依据(4 处)

SQLite row 的写入顺序决定事件看到什么

GoalStore 的不同写入口各自执行一条 SQL,不存在一笔包住“store + event”的显式事务。模型 create 用 complete-only 的 conditional upsert;外部 set 按上节选择 update 或 replace;status 和 progress 更新再根据 caller 是否提供 expected identity 限定命中。

只有写语句成功返回 durable row 后,各 caller 才投影变化。模型工具通过 extension event sink 发 EventMsg::ThreadGoalUpdated;app-server 的 thread/goal/set 则先取得 GoalSetOutcome,尝试追加 rollout item,再发 response 和 typed notification。事件是 projection,不是另一份 state store,也不存在一个统一的 runtime broadcaster 可以替所有 caller 保证相同交付顺序。

同一个原则适用于 accounting:先计算本次增量和新的累计值,再以 row identity 更新;冲突时返回 stale/missing error,不要把旧 handle 的数字静默合并到新 Goal。goal_state_lock 只协调接入它的 external mutation、runtime stop 与 idle continuation;model tool mutation 和 resume restore 不经过它,它更不能替代 SQLite 的条件更新。

本节源码依据(5 处)

Idle continuation 需要四个条件同时成立

active 只是必要条件,不是启动承诺

thread 进入 idle 后,Goal runtime 不会看到 active 就直接递归调用模型。它先确认 Goal extension 的工具仍然可见;再确认 manager 和 thread handle 仍然 live;然后重新读取或校验 durable status 仍是 active;最后把候选输入交给 try_start_turn_if_idle。其中任何一关失败,idle progress 可以结算,但不会生成下一次 turn。

这几道门各自回答不同问题:

检查解决的误判
visible Goal tools目标能力是否还挂在当前 thread,而不是只存在于数据库
live manager/thread进程或 thread 是否已经 shutdown、detached 或被替换
durable active用户刚刚 pause、runtime 刚标记 blocked,还是旧 handle 的 stale snapshot
try_start_turn_if_idle当前 turn、取消令牌和调度器是否真的接受新启动

因此 idle continuation 是一次重新授权,不是 timer 到期事件。它也解释了为什么 blocked 虽然非 terminal,却仍然停止自动继续:status gate 拒绝启动,直到用户或系统显式改变状态。

本节源码依据(2 处)

Durable Goal 变化如何投影为可观察事件

EventMsg::ThreadGoalUpdated 是某些 Core 调用路径产生的 projection,不是 Goal store 每次写入都会自动发出的 hook。它携带 thread-scoped Goal snapshot,让 app-server、TUI 或其他 consumer 更新局部视图;它不把 runtime handle、锁或未提交的 idle delta 暴露成协议状态。model tool 的 active -> blocked、user pause/resume 和 accounting 达到 limit 都可以在相应 caller 中产生事件,但原因与 caller 仍由各自 owner 保存;单独调用只改 durable row 的 API,不应被外推成必然有通知。

事件里的 turn_id 也要谨慎解释。resume 后的 idle accounting 测试看到 turn_id = None,正好说明这笔时间不属于一条正在执行的 turn;不能因为事件抵达 turn event stream,就把它归到上一条 turn。

本节源码依据(2 处)

指定实验:把 resume idle 与 final progress 分开验证

实验一:resume 只恢复 active Goal 的 idle accounting

运行的不是一个泛化的 smoke test,而是固定命名测试:

: "${ARCHIVE_CODEX_RS:?先执行第五部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-goal-extension thread_resume_rehydrates_active_goal_idle_accounting

结果:1 passed, 21 skipped

测试先准备一个 durable active Goal,再执行 resume;等待约 1.1s 后 flush idle progress,断言 durable time_used_seconds >= 1。它还检查发出的 Goal event 的 turn_id = None。这两项一起证明 idle wall-clock 被归到 Goal,而不是伪造一条 turn;单看 event 出现并不能证明 accounting 正确。

本节源码依据(1 处)

实验二:blocked 的最后一次 progress 只结算一次

运行:

: "${ARCHIVE_CODEX_RS:?先执行第五部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-goal-extension update_goal_can_block_and_accounts_final_progress

结果:1 passed, 21 skipped

fixture 给出 20 input tokens、5 cached input、8 output。按本章公式计算:

20 - 5 + 8 = 23

测试让模型侧 update_goal 把 Goal 从 active 变为 blocked,随后检查 durable row 的 tokens_used = 23,并检查事件顺序包含 active 后的 blocked。这里同时覆盖三个边界:cached input 被扣掉,blocked 不是 terminal,update_goal 的专用结算没有被 generic tool-finish 再收费。

本节源码依据(1 处)

这两个命令为什么算有意义的证据

两个命令都通过 package filter 和 exact test name 选择了目标测试;21 skipped 是同一 package 里其余测试被过滤,不是“运行了 22 个但只看一条”。相反,若 harness 只显示 running 0 tests,那只能说明筛选器没有命中,不能写成 Goal 行为已通过。

失败边界

  • 没有明确用户、system 或 developer 请求:模型不能调用 create_goal,普通 task 不会因为 prompt 看起来长期就自动持久化。
  • 已有 activepausedblockedusage_limited Goal:create_goal 不能覆盖当前 row;blocked 也不能借 replacement 绕过阻塞。
  • budget_limitedis_terminal() 返回 true,但创建 SQL 不允许替换它。只有 complete 允许新 Goal 覆盖同一个 thread_id row;覆盖后旧 Goal 不会留在一张历史表里。
  • 模型调用 update_goal:只能选择 completeblocked;pause、resume、usage limit 与 budget limit 应由用户、system 或 runtime 路径完成。
  • expected identity 缺失:不能声称所有写入都做 compare-and-swap;status tool mutation 传 None,而 external service、error stop 和 accounting writeback 才会选择性传 expected id。
  • generic tool-finish 没排除 update_goal:同一 turn 的 token delta 会重复结算,测试里的 23 可能被错误写成 46
  • 把 Plan turn 算入 Goal:预算会比实际模型工作更快耗尽;Plan 是控制流程,不是 Goal progress。
  • resume 只看到了 durable row:没有 visible tools、live manager/thread 或 try_start_turn_if_idle 的接受,不能启动下一 turn。
  • idle flush 与状态变化竞态:goal_state_lock 只协调 external mutation / stop / idle start,progress_accounting_lock 与 expected identity 才保护增量写回;不能假定模型 tool mutation 或 resume 也由前一把锁兜底。
  • 只看到 ThreadGoalUpdated:它是 projection,不是 durable commit 的替代品,也不能从 turn_id 缺失推断一次模型调用发生了。

交给第 30 章的边界

Goal 到这里停在 Core extension 和 runtime:它保存 objective,授权状态转换,累计属于当前目标的 progress,并在 idle 时请求下一次 turn。它不负责把这个事件变成客户端可以稳定消费的 JSON-RPC,也不拥有 app-server 的 connection、request serialization 或 notification subscription。

第 30 章接收三条边界:

  1. Goal extension 的 ThreadGoalUpdated 先进入 ExtensionEventSink;app-server 的 extension bridge 再补上 threadId 并走 global notification。这条路不经过普通 Core event 的 per-thread projector。
  2. Goal 的 goal_state_lock 与 app-server 的 request serialization scope 不是同一把锁:前者协调 external Goal mutation 与部分 stop/idle 路径,后者安排协议请求的资源顺序。
  3. Goal row、runtime handle、rollout History 和 app-server snapshot 各有 owner;把它们拼成一个“会话状态”会再次丢掉 resume、accounting 与事件的边界。

下一章用 ThreadGoalUpdated 做反例:普通 Core event 经过 per-thread listener 和 projector,Goal extension event 则通过独立 bridge 全局广播;app-facing Goal request 又是第三条路径。三者都不能被一句“Core 事件转 JSON-RPC”抹平。