← blog 技术原理与实践 · 2026-10-09

拆解 Codex 的 /goal:持久化目标系统、自动续跑与状态一致性

在 Codex 里输入 /goal 得到的不是一条注入模型的 prompt,而是一台持续运转的状态机:TUI 命令穿过 app-server RPC 落进独立 SQLite,运行时逐 token 计量消耗,线程空闲时自动开始下一轮,直到目标完成、卡住或预算耗尽。本文从 TUI 到 SQL 逐层拆开这台机器,重点讲它的核心架构决策——状态写入拆成「模型可写的子集」和「系统独占的派生状态」两条路径,一致性靠 SQL 层守卫与原子计量保证,而不是信任模型的自报。

14 min read

先给结论

/goal 的本质不是”给模型追加一条指令”,而是一个「TUI 命令 + app-server RPC + SQLite 状态机 + 生命周期计量 + idle 自动续跑」组成的持久化任务系统。其中最关键的架构决策是:状态写入被拆成”模型可写的子集”和”系统独占的派生状态”两条路径,一致性靠 SQL 层的守卫和原子计量保证,而不是信任模型的自报。

全景:五层架构,一条命令的完整路径

用户看到的是一个斜杠命令,但它要穿过五层才落到存储。每一层只依赖下一层的协议,不关心内部实现:

flowchart TD
    A["TUI(codex-rs/tui)<br/>SlashCommand::Goal 解析输入 → AppEvent<br/>确认弹窗、附件落盘"] -->|"AppEvent"| B["app-server 协议层<br/>thread/goal/get|set|clear<br/>(protocol/v2/thread.rs:866)"]
    B -->|"ThreadGoalRequestProcessor"| C["请求处理器<br/>feature 开关、持久化校验、goal 状态锁<br/>rollout 重放、用户意图写入"]
    C -->|"GoalService"| D["持久化:独立 SQLite(goals_1.sqlite)<br/>thread_goals 主表<br/>+ thread_goal_continuation_deferrals 延迟续跑表"]
    D -->|"GoalRuntimeHandle"| E["运行时(ext/goal)<br/>token/time 计量、预算检测、idle 自动续跑<br/>模型侧 get/create/update_goal 三个工具"]

注意一个容易误解的点:ext/goal 不是 /goal 文本命令的解析器,它是持久化目标的后端扩展;斜杠命令入口完全在 tui。

入口:TUI 如何把一行文本变成事件

命令定义在 slash_command.rs:139:Goal => "set or view the goal for a long-running task"。分发在 slash_dispatch.rs:384,逻辑比看上去克制:

  • feature 未开启(Feature::Goals)直接忽略;
  • 只有持久化线程才发送 AppEvent::OpenThreadGoalMenu——没有 thread_id 时只显示用法帮助;
  • /goal clear|edit|pause|resume 转成对应的 AppEvent,其余文本整体作为 objective;
  • 超长 objective、粘贴内容和图片先写入 $CODEX_HOME/attachments/...,目标里只保存”读取该文件”的引用(goal_files.rs:33);
  • 替换未完成目标前要弹确认框,已完成目标可直接替换(thread_goal_actions.rs:140)。

RPC 层:处理器的校验顺序是有讲究的

ThreadGoalRequestProcessor(thread_goal_processor.rs:133)按固定顺序执行:

  1. feature 开关检查,未启用直接拒绝;
  2. 要求持久化线程,拒绝 ephemeral thread;
  3. 持有 lock_goal_resume——把”用户修改目标”和”运行时自动续跑”互斥掉;
  4. reconcile_thread_goal_rollout 先重放 rollout,保证会话恢复后 DB 与记录一致,再读现状;
  5. 设置 active 目标时额外校验 model/provider 配置可用;
  6. 对用户操作,先把 UserGoalUpdate 写入 rollout(恢复会话时能重放用户意图),再调 GoalService 改库;
  7. 发出 ThreadGoalUpdated / ThreadGoalCleared 通知,并应用运行时效果。

持久化:独立的 goals 数据库

目标不放在线程 rollout 里,而是独立 SQLite(迁移在 state/goals_migrations/)。thread_goals 以 thread_id 为主键,保存 goal_id、objective、status、token 预算、已用 token、已用秒数和时间戳。每次”替换目标”都会重新生成 goal_id(goals.rs:163)——这个 UUID 后面是并发控制的枢纽。

运行时:计量、预算与自动续跑

GoalExtension 在 app-server 启动时注册一组钩子(extension.rs:587):线程开始建 GoalRuntimeHandle;turn 开始建 token 基线;token usage 事件记录增量并汇总子 agent;tool 完成累计 token/time 并检测预算;turn 停止/取消/错误时结算收尾;thread idle 时,如果目标仍 active,自动开始下一轮。

自动续跑在 runtime.rs:425 continue_if_idle,顺序很能说明设计取向:

  1. tools_available() 不可用就直接放弃并清掉 active goal;
  2. 查 deferral 表——用户推迟过续跑就直接返回;
  3. 持有 goal_state_permit,锁住”读 goal → 启动续跑”的整个窗口;
  4. 渲染 continuation.md,作为内部 ResponseItem 注入线程,明确要求保留完整 objective、依据当前 worktree 工作、并区分”真实进展 / 等待 / 无进展”;
  5. 调 start_turn_if_idle(),并标记这是 automatic goal turn,用于事后检测空响应。

空转是有代价的:连续三次自动续跑没有有效输出、或连续三轮工具全部失败,目标会被系统置为 blocked(accounting.rs)。

状态机:谁有权写什么

这是整篇文章的核心。状态不是单一写入者维护的,四类写入者走四条不同的代码路径,权限各不相同:

写入者允许的状态代码路径
模型(update_goal 工具)complete / blocked / pausedext/goal/src/tool.rs:243
系统计量active → budget_limitedstate/src/runtime/goals.rs:499
系统限流active / budget_limited → usage_limitedgoals.rs:436
用户(/goal set·pause·resume·clear)全部 + 整行替换/删除app-server RPC

模型侧入口的硬校验只有几行(tool.rs:248):

if !matches!(args.status,
    ThreadGoalStatus::Complete
    | ThreadGoalStatus::Blocked
    | ThreadGoalStatus::Paused)
{
    return Err(/* "resume、budget-limited、usage-limited
                  由用户或系统控制" */);
}

且 JSON schema 本身就是 string_enum(["complete","blocked","paused"])(spec.rs:63)——模型在协议层面连 active / budget_limited 都表达不出来。

完整的状态流转如下:

stateDiagram-v2
    [*] --> active : create_goal · /goal set
    active --> budget_limited : token 越界(系统计量派生)
    active --> usage_limited : API 限流(系统)
    budget_limited --> usage_limited : API 限流
    active --> paused : 用户暂停 / 模型(须用户显式要求)
    active --> complete : update_goal(模型判断)
    active --> blocked : update_goal(同一阻塞 ≥3 turn)
    paused --> active : 用户 resume
    blocked --> active : 用户 resume(blocked 审计清零重来)
    complete --> [*] : 可被新目标整行替换

一致性:SQL 层守卫,而不是信任

如果模型自己能改状态,怎么保证状态和事实一致?答案是分四层,一层比一层接近存储。

① 模型无权写系统状态。 budget_limited、usage_limited、active 只由计量和系统路径产生。模型的权限边界就是”我认为完成了 / 卡住了 / 该暂停”——这是主观判断,它无法伪造客观计量状态。

② SQL CASE 纠正非法转移。 就算绕过工具直接构造写入,update_thread_goal 里的 CASE 也会纠正(goals.rs:292):

status = CASE
  -- budget_limited 是粘性的:模型的 paused/blocked 无法降级它
  WHEN status = 'budget_limited' AND ? IN ('paused','blocked')
    THEN status
  -- 试图激活一个已超预算的目标:强制回到 budget_limited
  WHEN ? = 'active' AND token_budget IS NOT NULL
       AND tokens_used >= token_budget
    THEN 'budget_limited'
  ELSE ?
END

预算是最高优先级:预算耗尽时模型调 update_goal(complete) 不会被阻止(完成是合法结局),但想”复活”一个超预算目标不可能。仓库里有专门的回归测试钉死这两条(pausing_budget_limited_goal_preserves_terminal_status、activating_goal_already_over_budget_keeps_it_budget_limited)。

③ 改状态前先结算在途计量。 handle_update 在落库状态之前先调 account_active_goal_progress,把当前 turn 已消耗的 token/time flush 进 DB,并持有 progress_accounting_permit 防止并发交错——这样 SQL 里的 tokens_used >= token_budget 判断基于最新事实。目标完成时还会用 ActiveOrComplete 模式再记一次,保证 complete 的目标也留下收尾 turn 的消耗。

④ 乐观并发控制。 所有 UPDATE 都带 expected_goal_id = ? 条件,替换目标会换新 goal_id,旧版本目标的写请求影响行数为 0、整个调用返回 None(测试 update_thread_goal_ignores_replaced_goal_version)。token 增量用 tokens_used = tokens_used + ? 原子累加,并发 turn 的计量不会互相覆盖(usage_accounting_adds_concurrent_token_deltas)。

边界:什么被验证,什么被信任

机械可验证的:token 消耗、时间消耗、预算越界——这些是计量事实,由系统独占写入路径和 SQL 原子 UPDATE 保证,状态与事实严格一致,无法伪造。

本质上靠信任的:complete 和 blocked 是模型的主观判断,代码无法验证”目标真的达成了”。系统对它们的约束是行为层面的:

  • 用 prompt 写死纪律:blocked 要求同一阻塞条件连续 ≥3 个 goal turn,resume 后重新审计,明令禁止”因为难/慢/想澄清”就 blocked(spec.rs:66 的工具描述);
  • 让状态产生系统后果:非 active 状态都失去 idle 自动续跑资格,虚报等于提前放弃自动推进,模型无利可图;
  • 留审计痕迹:每次状态变化记 metrics/analytics,目标行保留在 DB 里——用户随时可以核对”自称完成”的目标与实际工作产物。

所以准确地说:这套系统保证的是计量事实(token、时间、预算)与状态严格一致,这是机械可验证的;而**语义事实(目标是否真完成)**靠”限制模型的状态权限 + 让虚报无利可图 + 留审计记录”来约束,这是行为层面的。

结语:可借鉴的设计要点

  • 把”判断”和”计量”拆成两条写入路径。 模型的主观结论(完成/卡住)可以被接受,但任何能被客观测量的量(token、时间、预算)绝不让模型染指,并在 SQL 层用 CASE 兜底。
  • 让系统状态不可表达,而不只是不可授权。 工具 schema 直接枚举出合法值,比运行时校验更早、更省 token。
  • 自动续跑要配”刹车”。 deferral 表、空转检测、连续失败计数,防止一台自动运转的机器在错误的轨道上烧预算。
  • goal_id 即版本号。 一次替换换一个新 UUID,旧请求的写入自然失效,乐观并发不需要额外字段。

文中所有引用基于 codex-rs 仓库 main 分支:入口 tui/src/chatwidget/slash_dispatch.rs:384,协议 app-server-protocol/src/protocol/v2/thread.rs:866,处理器 app-server/src/request_processors/thread_goal_processor.rs:133,存储 state/src/runtime/goals.rs,运行时 ext/goal/src/{runtime,tool,spec,accounting}.rs。状态流转图中”≥3 turn”的规则定义在 ext/goal/src/spec.rs:66 的 update_goal 工具描述中。

Sources

  1. openai/codex 仓库(codex-rs) — 本文全部代码事实的一手源,main 分支
  2. tui/src/chatwidget/slash_dispatch.rs — /goal 斜杠命令的 TUI 分发入口(L384)
  3. app-server-protocol/src/protocol/v2/thread.rs — thread/goal/get|set|clear 三个 JSON-RPC 的协议定义(L866)
  4. app-server/src/request_processors/thread_goal_processor.rs — 处理器校验顺序:goal 锁、rollout 重放、provider 检查(L133)
  5. state/src/runtime/goals.rs — goal 存储层:update_thread_goal 的 CASE 守卫、原子计量 UPDATE(L292/L499)
  6. ext/goal/src/runtime.rs — continue_if_idle 自动续跑的五步顺序(L425)
  7. ext/goal/src/tool.rs + spec.rs — update_goal 工具的写入校验(L248)与 blocked≥3 turn 的 prompt 纪律(L66)

Related

On this page 先给结论全景:五层架构,一条命令的完整路径入口:TUI 如何把一行文本变成事件RPC 层:处理器的校验顺序是有讲究的持久化:独立的 goals 数据库运行时:计量、预算与自动续跑状态机:谁有权写什么一致性:SQL 层守卫,而不是信任边界:什么被验证,什么被信任结语:可借鉴的设计要点