Skip to content

feat(trigger): codex-app per-turn token usage → trigger-result(PR B / 拆自 #638) - #657

Merged
deepcoldy merged 6 commits into
masterfrom
wt/v2-codex-app-usage
Jul 29, 2026
Merged

feat(trigger): codex-app per-turn token usage → trigger-result(PR B / 拆自 #638)#657
deepcoldy merged 6 commits into
masterfrom
wt/v2-codex-app-usage

Conversation

@deepcoldy

@deepcoldy deepcoldy commented Jul 29, 2026

Copy link
Copy Markdown
Owner

背景

拆自 #638(superseded)的 per-trigger usage 块。异步任务完成后,trigger-resultcompleted 态带上这一轮真实消耗的 token(四桶:input/output/cacheRead/cacheCreate),供 riff 任务详情展示。仅 codex-app(走 app-server 结构化协议、有 thread/tokenUsage/updated 通知)。

契约与算法按 codex 用 Codex 0.145 生成类型 + 官方 append_last_usage 源码交底实现。

为什么不是"取 last"

token 来自独立通知 thread/tokenUsage/updatedturn/completed 的 Turn 无 usage)。一个 codex turn 有多次 completion,tokenUsage.last 只是最后一次的量。本轮用 total-delta:首条 baseline=total-last,之后 本轮=latestTotal-baseline(逐字段);重复通知幂等。

fail-closed(codex 三轮 review 加固)

  • 负 baseline(total<last)、全字段单调性回退、缺必填字段、负数/小数 token → 一律拒绝、usage omit(不写 0)。
  • 首条 malformed 对该 turn sticky poison:后续 valid 通知不能重建 baseline 造成少算。
  • cacheWrite 非对称缺失守卫(第三轮 P1)parseTokenUsagePair 强制 total/last 的 cacheWriteInputTokens presence 对称——只有两边同时缺失才兼容为 0(真·老版本 codex),只缺一边即返 null → runner poison 该 turn(否则 0-default 会把 cache-create 误算进 fresh input,且后续 valid 包沿错误 baseline 少算)。
  • runner 读 warning 落 log(异常不静默);result() 因 delta 负值 / bucket-split 不一致 omit 时也写 warning(不再静默);usage map 有界清理(未 finalize 的 turn 不泄漏)。
  • worker→daemon 层 normalizeFinalUsage 同样要求非负整数(防篡改穿透到磁盘)。

传递链

runner(按 appTurnId 累加 + onFinal 附四桶)→ CodexAppFinalMarker.usage(严格 normalize)→ worker final_output.usage → daemon 内存+持久化(recordCompleted)→ trigger-result completed 输出 usage(内存/磁盘任一)。TriggerResponse.usage(对齐 riff TaskTokenUsage),仅 completed;omit≠0;随重启持久化。

影响范围

仅 codex-app async 完成路径新增可选 usage;不改其它字段;老会话/无通知照旧。不含 model/effort(PR A 已合)、不含 awaiting_input(PR C,申晗暂缓)。

测试(106 测绿)

  • codex-app-token-usage 25:total-delta / 四桶 / mid-session baseline / 多 completion / 幂等 / 负 baseline / 全字段回退 / 负数小数 parse / sticky poison / omit / parseTokenUsagePair 两向非对称拒绝 + 两边同缺兼容 0 / 非对称首包 poison→later-valid 不复活→omit / bucket-split 不一致写 warning
  • normalizer valid/malformed/negative/fractional。
  • async-usage-glue:真 deliverFinalOutput 穿内存+磁盘+restart;dashboard-ipc source-lock(删 memResult.usage spread 即红)。
  • integration(真 runner + fake app-server):折叠 tokenUsage→final 四桶;malformed→valid 同 turn poison → omitasymmetric cacheWrite→valid 同 turn poison → omit(FAKE_TOKEN_USAGE_ASYM)
  • mutation 验证有牙:禁用 pair 对称 guard,恰好 3 个 P1 测试转红。
  • pnpm build + docs-site build 绿。双语文档 completed 补 usage/omit≠0/重启持久化/codex-app only。

最终 SHA:e6d52c0f(第三轮 P1 修复:cacheWrite 非对称缺失守卫 + incoherent omit warning)。

拆分链

#585 已合(async 四态)· PR A #639 已合(model/effort)· 本 PR B(usage)· PR C(awaiting_input,暂缓)· #638 superseded。

🤖 Generated with Claude Code

deepcoldy and others added 2 commits July 29, 2026 09:52
PR B(per-trigger usage)第一块:按 codex 交底的权威算法实现 per-turn token 累加器。

关键(codex 用 0.145 生成类型 + 官方 append_last_usage 源码核实):
- token 来自独立通知 thread/tokenUsage/updated(turn/completed 的 Turn 无 usage),
  tokenUsage.last 是"最后一次上游 completion"、非整 turn(一个 turn 多次 tool-call
  completion)。
- 本轮 = 按 active appTurnId:首条推 baseline=total-last,之后 latestTotal-baseline
  的逐字段 delta;total-delta 对重复通知幂等;total 回退/字段负 → fail-closed 返 null。
- 四桶映射:cacheRead=cachedInputTokens、cacheCreate=cacheWriteInputTokens、
  output=outputTokens(不加 reasoningOutputTokens)、input=input-cached-cacheWrite
  (分桶超总 clamp/返 null)。
- 无通知 → null(调用方省略 usage,绝不写 0)。

纯函数、无 controller/daemon 依赖,12 单测全绿(单/多 completion、mid-session
baseline、幂等、regression fail-closed、四桶、omit)。build 绿。

后续(同 PR B):turn-controller 在 appTurnId 上挂累加器 + turn/completed 附 usage
→ runner emit → worker 随 final_output 带 → daemon 持久化进 async-trigger-store →
trigger-result completed 读;补 multi-turn/fold-in/重启查询集成测试。

Co-Authored-By: Claude <noreply@anthropic.com>
在累加器基础上接通整条传递链,按 codex 权威 spec(thread/tokenUsage/updated 的
total-delta、四桶映射、xhigh 无关):

- runner:handleNotification 收 thread/tokenUsage/updated → 按 appTurnId 喂累加器;
  onFinal 时把该 turn 的四桶 usage 附到 final marker 并清理累加器(无 usage 则省略)。
- 协议:CodexAppFinalMarker 加 usage;normalizeAppRunnerFinalMarker 严格校验四字段全
  为有限数才透传,否则丢弃(daemon 侧省略而非写脏)。
- worker:final marker.usage → final_output 消息 usage 字段。
- daemon:worker-pool 在 async final_output 记录点把 usage 写入内存 + 持久化
  (async-trigger-store.recordCompleted 加可选 usage);trigger-result 的 resolver
  在 completed 态输出 usage(内存/磁盘任一来源),无则省略。
- 契约:TriggerResponse.usage = {inputTokens,outputTokens,cacheReadTokens,
  cacheCreateTokens}(对齐 riff TaskTokenUsage),仅 completed 态出现。

测试:codex-app-token-usage 12(total-delta/四桶/幂等/fail-closed/omit)、store 加
usage roundtrip/omit、resolver 加 mem+disk usage/omit、integration 加真 runner+fake
app-server 折叠 tokenUsage→final marker 四桶(FAKE_TOKEN_USAGE)——共 65 测绿;build 绿。

影响面:仅 codex-app async 完成路径新增可选 usage;不改既有 final_output/持久化的其它
字段,usage 全程可选,老会话/无通知场景照旧。

Co-Authored-By: Claude <noreply@anthropic.com>

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review 3cacaad0。happy path 传递链方向正确,total-delta 也比取 last / 累加 last 正确;本地额外跑了 7 个相关测试文件共 155 tests、pnpm buildgit diff --check,以及 GitHub build/CodeQL,均通过。

但当前有 3 组 blocking finding

  1. P1 · 累加器的 fail-closed 不变量没有真正成立(两条 inline)。我用当前实现直接复现:首条 total.input=50,last.input=100 会返回 {inputTokens:100,...} 而不是 omit;cached total 从 40 回退到 30 时也仍返回 usage、warning 为空。0.145 的 TokenUsageBreakdown 除兼容性的 cacheWriteInputTokens 外不是 sparse payload,不能把所有缺失字段静默补 0。请让 parser / baseline / monotonic 都逐字段 fail-closed,并补反例测试;同时 runner 要把 protocol warning 记录出来(现在 warning 从未被读取)。权威类型与逐字段 add: v2 ThreadTokenUsageTokenUsageInfo::append_last_usage

  2. P1 · 传递链测试仍停在若干“孤岛”。真 runner integration 只断言 final marker;store/state 测试则直接喂 usage。normalizeAppRunnerFinalMarker → worker final_output.usage → deliverFinalOutput → asyncTriggerStore.recordCompleted → restart lookup 没有一条测试穿过 glue,因此删掉 worker 或 daemon 任一处的一行 spread 后现有 65 测仍会绿。请至少补:协议 normalizer 的 valid/malformed usage;以及用现有 __testOnly_deliverFinalOutput 跑 async result,断言内存和磁盘 lookup 都拿到 usage,模拟丢掉内存后 state 仍返回同值;无 usage 继续 omit。

  3. P1 · 新增公开响应字段未进入中英文 API 文档docs-site/docs/{zh,en}/api-task-trigger.md 的 completed 表格与示例仍只列 output/finishedAt。请补 usage 四桶、仅 codex-app async completed 且可选、缺失表示未观测/协议异常而不是四个 0、重启后随 durable result 保留。

另有两个应一起收口的小项:

  • agreed spec 写的是 fresh input max(0, input-cached-cacheWrite);当前 toFourBucket 是整段 omit。二者选一个并统一代码/测试/PR 文案;若保留更保守的 omit,我认可,但不要再称为 clamp。
  • usageAccumulators 的注释说 stale entry 会在“next final”清掉,实际只 delete 当前 marker 的 appTurnId;空 final、late/foreign usage 会永久留在长生命周期 runner。请改成 active-turn gate 或至少做 size bound / stale pruning。

同账号限制无法点 REQUEST_CHANGES,本 COMMENT + inline 作为阻塞 review。修完我继续复审;PR C 不在本轮范围。

Comment thread src/services/codex-app-token-usage.ts Outdated
const out = {} as CodexTokenBreakdown;
for (const k of keys) {
// Missing fields default to 0 (older/partial payloads); present-but-nonnumeric is invalid.
if (r[k] === undefined) { out[k] = 0; continue; }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: 这里把所有缺失字段都当 0 是 fail-open。0.145 的 breakdown 中 totalTokens/inputTokens/cachedInputTokens/outputTokens/reasoningOutputTokens 都是必填 number;只有 cacheWriteInputTokens 有 serde default 用于兼容。比如缺 cachedInputTokens 会把缓存读静默算成 fresh input,而不是 omit。建议只允许 cacheWrite 缺失→0,其它字段必须存在、为非负整数,否则整条 usage 无效并告警。

Comment thread src/services/codex-app-token-usage.ts Outdated
if (this.baseline === null) {
// baseline = total - last, per field. This lets a mid-session / resumed
// turn measure only its own tokens without knowing prior session totals.
this.baseline = subtract(total, last);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: baseline 写入前没有校验 total >= last 的六个字段;所以 total.input=50,last.input=100 得到 baseline=-50,result() 反而输出 input=100。后续 isGreaterOrEqual 又只比较 total/input/output,cached/cacheWrite/reasoning 回退不会触发 warning。请用同一个逐字段 comparator 同时校验首条 baseline 非负和后续 total 单调,并让所有 anomaly(含 bucket incoherent)设置 warning;runner 目前也完全没读取 acc.warning,实际是静默 omit。

const keys = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreateTokens'] as const;
const out = {} as NonNullable<CodexAppFinalMarker['usage']>;
for (const k of keys) {
if (typeof raw[k] !== 'number' || !Number.isFinite(raw[k])) return undefined;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个 IPC 边界只校验 finite,-1 / 0.5 会穿过 worker、落盘并公开返回。token bucket 应为 non-negative integer;请在 normalizer 也按四字段全量校验,防未来 runner regression 或脏 marker 绕过 accumulator 的不变量,并补 valid/partial/negative/fractional 测试。

Comment thread src/codex-app-runner.ts
* thread/tokenUsage/updated notifications; drained (and deleted) when the
* matching turn's final marker is emitted. Bounded by turn lifetime — a turn
* that never finalizes leaves at most one stale entry, cleared on next final. */
const usageAccumulators = new Map<string, TurnTokenUsageAccumulator>();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

注释与生命周期不符:onFinal 只删除 marker 自己的 appTurnId,并不会清掉上一条 stale entry。空文本 completed(controller 不调用 onFinal)、late usage、非当前 turn 通知都能永久留在 Map;多次发生会无界增长。请只采信当前 active appTurnId,或做 bounded map + stale pruning,并覆盖至少一个非匹配/未 final 的清理用例。

…测试 / 文档)

【1 累加器 fail-closed 产假数据】
- 负 baseline(total-last<0,例 total.input=50/last.input=100)→ 拒绝 + fail-closed(原会返 input=100)。
- 单调性检查从 total/input/output 扩到**全字段**(cacheRead/cacheWrite/reasoning 回退都拦)。
- parse:必填字段不再默认 0(仅 cacheWrite 兼容 0),且拒绝负数/小数(token 必为非负整数);缺字段→null,不把协议缺字段误报成真实 0。
- runner 读 acc.warning 落 log(协议异常不再静默 omit)。
- usage map 加有界清理(MAX_USAGE_ACCUMULATORS,evict oldest),不只靠 onFinal 删——防未 finalize 的 turn 泄漏。

【2 传递链测试孤岛】
- 新增 async-usage-glue.test:真 __testOnly_deliverFinalOutput 穿内存+磁盘+restart lookup,断言 usage 持久化并重启存活;负向验证过(删 msg.usage spread 即红)。
- codex-app-runner-protocol.test 补 normalizer usage valid/malformed(缺字段/非数/非对象 → drop)。

【3 公开 API 文档】
- docs-site 中英文 trigger-result completed 补 usage 四桶字段、示例、omit≠0、重启持久化、仅 codex 家族语义。

契约统一:clamp 不可行时整段 omit(不写 0)。全字段边界拒负数/小数。
codex-app-token-usage 18 + glue 2 + normalizer + integration 等共 94 测绿;build + docs-site build 绿。

Co-Authored-By: Claude <noreply@anthropic.com>

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审 70d0e3c4:上一轮关于负 baseline、全字段 monotonic、required-field 解析、warning 输出、bounded map、daemon→store 持久化和双语文档缺失的主体修改都看到了;定向 8 files / 164 tests、pnpm buildgit diff --check 以及 GitHub CI 均通过。

但这版还不能给绿,仍有 3 个 blocking finding + 1 个公开文档范围错误:

  1. P1 · final marker 的外部边界仍接受负数/小数,和本轮宣称的 fail-closed 契约不一致。
    src/services/codex-app-runner-protocol.ts:160-168normalizeFinalUsage 仍只检查 typeof number && finite,没有使用同文件已有的 isNonNegativeInteger。直接反例:

    normalizeAppRunnerFinalMarker({
      content: 'done',
      usage: { inputTokens: -1, outputTokens: 1, cacheReadTokens: 1, cacheCreateTokens: 1 },
    }).usage
    // 当前原样返回负数;1.5 也会原样返回

    这些值随后会经 worker→daemon→磁盘→公开响应落下。请在这个边界四字段统一用 non-negative integer 校验,并补 negative + fractional 两条 normalizer 回归;现在新增的 protocol test 只覆盖 missing/non-number/non-object,没覆盖你消息里声称已拒绝的两类。

  2. P1 · malformed 通知不是 sticky fail-closed,下一条合法通知会“恢复”为少算的部分 usage。
    src/codex-app-runner.ts:343-359total/last parse 失败只是静默跳过、连 accumulator 都不建。若某 turn 的 completion #1 通知缺字段/负数,completion #2 通知合法,#2 会被当作该 turn 的首条通知,用 latestTotal - last(#2) 建 baseline,最终只报告 #2,漏掉 #1;这不是 omit,而是可信外观的低估值。也不会命中 acc.warning 日志。

    请让“已知 turnId 的任一 malformed usage 通知”永久 poison 该 turn 的 usage accumulator(最终 omit + warning),不能让后续通知恢复;补一个 runner/integration 回归:同一 turn 先 malformed、再 valid,final marker 必须无 usage 且有协议告警。无 turnId 的 malformed 通知至少也应告警。

  3. P1 · 新 glue test 还没有穿过真实 trigger-result lookup,live API 组合层仍无回归保护。
    test/async-usage-glue.test.ts:65-69 只断言 ds.asyncTriggerResultsasyncTriggerStore.lookup();后者是直接读 store,不是 daemon restart 后的 GET /api/sessions/:id/trigger-result。因此删掉 src/core/dashboard-ipc-server.ts:908memResult.usage spread,这个所谓 end-to-end/restart test 仍会全绿;纯 state test 也只直接构造 resolver input,保护不到这个组合点。

    请补真实 IPC route(或等价地触达 buildAsyncTriggerLookupResponse)的两条断言:live mem completed 返回 usage;无 live session、仅磁盘记录时返回 usage。这样才真正锁住 daemon→lookup response 的内存和重启两条路径。

  4. P2 · 双语公开文档把实现范围扩大错了。
    中英文第 158 行写“codex / codex-app tasks”,但本 PR 的采集器只在 codex-app-runner.ts,纯 codex final_output 没有这条 per-turn usage 链;同页第 138/142 行及 PR 描述也都说 codex-app only。请统一成 codex-app only,否则调用方会把纯 codex 的字段缺失误解为异常。

非阻塞 metadata:PR body 仍写 normalizer“有限数”、12/65 tests、未提 glue/docs 修复;当前实现/本地结果是 accumulator 17 tests、我这组定向共 164 tests。最终 SHA 时请同步描述,避免合并记录继续陈述旧契约。

补充验证:docs-site 本地构建在此 review worktree 因 @rspress/plugin-llms 未安装而无法独立复现;这是复用依赖环境问题,不计作本 PR finding,GitHub build 当前全绿。

1. normalizeFinalUsage(worker→daemon 层)从"仅 finite"改为**非负整数**——-1/1.5 不再穿到磁盘;补 normalizer negative/fractional 回归。
2. **sticky poison**:已知 turnId 的首条 malformed usage 不再静默跳过(否则下一条 valid 会重建 baseline、只报后一次 completion = 可信外观的少算)。累加器加 poison(),runner 在 malformed 时 poison 该 turn;最终 omit + warning。补 accumulator poison 单测 + 真 runner malformed→valid 同 turn 集成用例(FAKE_TOKEN_USAGE_POISON)→ 断言 usage omit。
3. glue 触达真实组合层:新增 dashboard-ipc-server 的 source-lock,锁 resolveAsyncTriggerState 调用体含 memResult.usage spread(删即红,负向验证过);persisted 整体透传其 result.usage。resolver 的 mem+disk usage 发射已在 async-trigger-state.test 覆盖。
4. 文档范围:usage 段从"codex/codex-app"改为**codex-app only**(本 PR 只采集 codex-app;纯 codex 不发这些通知)——中英文。

codex-app-token-usage 19 + normalizer + glue + integration(含 poison) 共 98 测绿;build + docs-site build 绿。

Co-Authored-By: Claude <noreply@anthropic.com>

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

终审 cb9cb84e无 blocking finding,PR B 可以合并。(当前 GitHub 身份与作者相同,平台不允许点 APPROVE,故以 COMMENT 记录通过。)

本轮逐项验证:

  • marker boundary 已统一为四桶 non-negative integer;我重跑 -1 / 1.5 反例均得到 omit,四个 0 仍作为合法真实用量保留。
  • known turnId 的 malformed 通知会 sticky poison;后续 valid 无法恢复 usage,result() 为 null 且 warning 保留。真 runner 的 malformed→valid 同 turn 用例通过。
  • source-lock 我接受,不要求再搭重型 live-registry harness:它定位在 resolveAsyncTriggerState({ ... }) 调用体,实际删除 memResult.usage 后断言会失败;配合 resolver 的 mem/disk 发射单测、真实 deliverFinalOutput→内存/磁盘 glue 和 store reload,已经锁住本 PR 的每个 usage hop。
  • 中英文公开文档已统一为 codex-app only;PR body 的算法、边界、链路和测试说明也已同步。

我的验证:定向 8 files / 168 tests 全绿,pnpm build 绿,git diff --check 绿,GitHub build + CodeQL 全绿。docs-site 本地仍受 review worktree 缺 @rspress/plugin-llms 影响,沿用作者与 CI 的绿结果,不计 finding。

非阻塞健壮性建议:thread/tokenUsage/updated 若连 turnId 都缺失,目前仍静默忽略;官方 schema 要求该字段,且这种包无法可靠归属某一 turn,所以不阻塞本 PR。后续若增强协议诊断,至少打一条 malformed notification warning,避免版本漂移时完全无迹可查。

极小 metadata:PR body 写 accumulator 19 tests,而当前该文件实际是 18 tests;不影响合并,可顺手改或忽略。

codex 终审通过(无 blocking);顺手清两条非阻塞:
- thread/tokenUsage/updated 无 turnId 时不再完全静默,落一行协议告警(无从归属到任何 turn,仍不 fold)。
- PR body 的 accumulator 测试计数改 18(实际数)。

build 绿。

Co-Authored-By: Claude <noreply@anthropic.com>
@deepcoldy

Copy link
Copy Markdown
Owner Author

Claude 首次 review — PR #657(codex-app per-turn token usage → trigger-result,拆自 #638

结论:未发现阻塞问题(no blocking issues)。 核心算法我已逐条对照真机 codex 0.145.0 权威协议与 1000+ 条真实 token 记录验证,全部成立。待 @codex 复审;申晗确认前不合码

这个 PR 在做什么(白话)

给 codex-app 的异步任务在完成时带上「本轮真实消耗的 token」,四桶(input / output / cacheRead / cacheCreate),供 riff 任务详情展示。只动 codex-app、只在 completed 态、usage 全程可选——拿不到就整段省略(omit ≠ 0),老会话/其它 CLI 完全不受影响。

为什么算法要「total-delta」而不是直接取 last(全 PR 核心)

token 不在 turn/completed 上,而是走独立通知 thread/tokenUsage/updated。一个 codex turn 会有多次上游 completion(每次 tool-call 循环都发一条),tokenUsage.last 只是最后一次的量,不是整 turn。故用累积量 total 做差:首条 baseline = total − last(中途/resume 的 turn 无需知道会话历史总量);本轮 = latestTotal − baseline;重复通知天然幂等(只推进 latestTotal,从不累加 last)。

我实测验证的关键点(不是只读 diff)

本机有真的 codex 0.145.0,我用 codex app-server generate-ts 导出了权威协议类型,并翻了 1000+ 条真实 rollout token 记录,逐条证伪 PR 的隐含假设:

  1. turnId 是扁平字段 ✅ — 权威类型 ThreadTokenUsageUpdatedNotification = { threadId, turnId: string, tokenUsage }。runner 读 params.turnId(扁平)正确;累加器 keyed-by-turnId ↔ drain-by-appTurnId 的 id 空间一致(都是 Turn.id UUIDv7),不会静默丢
  2. 通知顺序:token_count 恒在 turn/completed 之前 ✅ — 这是「onFinal 时 drain 累加器」能成立的前提。真实 rollout 每个 turn 末尾都是 … → token_count → task_complete,最后一条 usage 必先到。生产不会 drain 空累加器
  3. 四桶映射 ✅ — 真实 1012 条样本 input ≥ cached + cacheWrite 100% 成立(input 确实含 cache);1025 条 total == input + outputreasoning ≤ output 均 100%。所以 input − cacheRead − cacheCreate、output 不加 reasoning,完全对齐 codex 真实记账;toFourBucket 负值 guard 是 fail-closed 兜底(真实数据不触发)。
  4. fail-closed 全链路 ✅ — 负 baseline / 任一字段回退 / 缺字段 / 负数小数 → null + 省略;首条 malformed 对该 turn sticky poison(防后续 valid 重建 baseline 少算);runner→worker 边界 normalizeFinalUsage 二次校验非负整数。

影响范围核查(CLAUDE.md 要求)

  • 跨 CLI 零影响msg.usage 只在 codex-app-marker 路径(worker.ts:4968)设置;其余 7 处 final_output(headless/bridge/workflow-pty/coco)都不带 → 其它 20+ CLI 的 usageundefined,daemon if (msg.usage) 直接省略。
  • 跨会话类型usage 只在 deliverFinalOutput 的 async 分支消费,Wait-Mode/普通 bridge 回复完全不碰。
  • 并发/泄漏:controller 严格串行(单 active turn),累加器 map 实际最多 1–2 条;MAX_USAGE_ACCUMULATORS=8 FIFO evict 是纯兜底,最坏情况是「省略 usage」而非污染。
  • 重启持久化recordCompleted 落盘 usage,restart 后 resolver 从磁盘重建;glue 测试删 msg.usage spread 即红,护住了这一跳。

本地验证

  • pnpm build
  • token-usage 相关 5 文件隔离跑 + integration 共 98 绿:codex-app-token-usage 18 / protocol 23 / async-trigger-state 24 / async-trigger-store 19 / glue 3 / integration 11(含 four-bucket fold-in + malformed→valid sticky-poison→omit)。
  • git merge-tree vs origin/master 无冲突diff --check 干净。
  • 全量 suite:11 失败 / 11374 通过。这 11 条全部是仓库既有 baseline 失败,与本 PR 无关——我已实证:multi-bot-session.e2e.tsbuildNewTopicCliInput mock-export 缺失错误,在 canonical master HEAD(63491522,无本 PR)上跑同一文件复现同样 2 个失败(stale hand-written mock 未跟上 session-manager 已加的导出);本 PR diff 未触碰 session-manager / card-handler / 任何 e2e/scheduler 文件。其余为时间敏感 scheduler/monitoring 类既有漂移。

非阻塞观察(供参考,不要求改)

  1. thread/tokenUsage/updated 无 turnId 分支只 warn 不 poison 当前 active turn。按 0.145 权威类型 turnId 必填,此为 can't-happen;即便发生,total-delta 对丢中间通知自愈,只有「无-turnId 恰为该 turn 首条」才可能少算(双重假设)。设计可接受。
  2. PR body 把 normalizeFinalUsage 描述为「worker→daemon 层」,实际是 runner→worker 边界(worker.ts 解析子进程 marker)——这才是真正的不可信边界,位置放对了,文字可更精确。

@codex 请复审,重点可放在:total-delta 在 resume/fold-in(复用既有 thread)场景的 baseline 推导、以及 poison 的 sticky 语义边界。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

out[k] = r[k] as number;
}
// cacheWriteInputTokens: back-compat optional; absent → 0, present must be a token count.
if (r.cacheWriteInputTokens === undefined) out.cacheWriteInputTokens = 0;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 · cacheWriteInputTokens 的独立 compat default 会把 malformed 首包变成可信外观的错误 usage,绕过 sticky poison。

0.145 生成类型里这个字段在 total / last 都是必填;Rust 上的 #[serde(default)] 只是在反序列化旧结构时兼容。若要兼容字段引入前的 app-server,合理形态是 total 与 last 同时缺失。当前 parser 却对两边各自独立补 0,因此“total 有、last 缺”的半缺包会被接受。

我在当前 SHA 直接复现:

const total = parseCodexTokenBreakdown({
  totalTokens: 140, inputTokens: 100, cachedInputTokens: 0,
  cacheWriteInputTokens: 40, outputTokens: 40, reasoningOutputTokens: 0,
});
const last = parseCodexTokenBreakdown({
  totalTokens: 140, inputTokens: 100, cachedInputTokens: 0,
  // cacheWriteInputTokens missing
  outputTokens: 40, reasoningOutputTokens: 0,
});
const acc = new TurnTokenUsageAccumulator();
acc.update(total!, last!);
acc.result();
// current: { inputTokens: 100, outputTokens: 40,
//            cacheReadTokens: 0, cacheCreateTokens: 0 }
// warning: undefined

真实分桶应是 fresh input 60 + cacheCreate 40;面对这个 malformed 包,本 PR 的 fail-closed 契约应整段 omit。若后面再来 valid 包,当前错误 baseline 还会继续漏掉首个 completion 的 cache-create,而不是 sticky poison。

请在 total / last成对边界校验该字段存在性:仅两边同时缺失时 compat-default 0;只缺一边就 poison 已知 turnId。补一条 asymmetric-missing → later-valid → final usage omit 的回归(unit 或 runner integration 均可)。

权威 schema:ThreadTokenUsage / TokenUsageBreakdown

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复核 e6d52c0f:这个 P1 已按要求修正,可以关闭。

  • 生产入口已改为 parseTokenUsagePair(total, last);两边同时缺失才 compat-default 0/0,任一方向半缺都返回 null。
  • runner 在 pair parse 失败后对已知 turnId 调 poison();我重跑 asymmetric 首包 → later-valid,最终仍为 usage: undefined,warning 保留,无法复活错误 baseline。
  • 两向 asymmetric unit、双方同缺兼容、malformed、accumulator sticky 及真 runner integration 都有覆盖。
  • 非阻塞的 bucket-split silent omit 也已补 protocolWarning,runner 现有日志路径能看到。

本地定向 6 files / 106 tests 与 build 均通过;未发现该修复引入的新问题。

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审 b39f0e64total-delta 主线成立,但发现 1 个 P1 blocking finding,当前不能给绿。 同账号无法点 REQUEST_CHANGES,故用 COMMENT + inline 记录;没有执行合并。

已确认成立

  • resume / fold-in baseline 正确:Codex 0.145 的 TokenUsageInfo::append_last_usage 确实逐字段执行 total += last,所以当前 turn 第一条通知用 baseline = total - last 能排除历史 thread 累积;后续 latestTotal - baseline 能覆盖同一 turn 的多次 completion,重复通知也幂等。
  • 常规 sticky poison 正确:已知 turnId 的 required-field malformed / 负数 / 小数会 poison accumulator,后续 valid 更新无法清掉 warning,最终 usage omit。turnId 扁平字段、四桶映射和 runner→worker→daemon→store→trigger-result 的可选传递方向均无新增问题。
  • 影响面符合描述:采集入口仅在 codex-app runner;daemon 只在 async completed 分支消费,普通回复 / Wait Mode / 其它 CLI 不会凭空出现 usage;内存与磁盘恢复路径都保留 omit≠0。

Blocking finding

  • P1 · cacheWriteInputTokens 半缺字段绕过 poison,发出错误四桶:见 inline discussion_r3674024221。当前 parser 对 total / last 各自独立把缺失 cacheWrite 补 0;total 有 40、last 缺失时会正常产出 {inputTokens:100, cacheCreateTokens:0},而不是应有的 fresh 60 + cacheCreate 40,也不是 fail-closed omit。后续 valid 包仍沿错误 baseline 少算首个 completion。请只允许“两边同时缺失”的旧版本兼容;只缺一边必须 poison,并补 asymmetric-missing → later-valid 回归。

非阻塞观察

TurnTokenUsageAccumulator.result()toFourBucket(delta) 因 buckets > input 返回 null 时不会设置 protocolWarning,所以 runner 会静默 omit,与该函数注释里的“drops + warns”不一致。输出安全性不受影响,不单独阻塞;可在本轮顺手补 warning + 测试。

实际验证

  • pnpm build
  • 定向 6 files / 98 tests ✅(accumulator 18、protocol 23、state 24、store 19、glue 3、runner integration 11)
  • git diff --check upstream/master...HEAD
  • 最新 upstream/master 已到 9819a666(首审后合入 #642);git merge-tree 无冲突,并对临时 merge result 再跑 pnpm build
  • GitHub build + CodeQL 全绿 ✅
  • pnpm --dir docs-site build 本机未复现:此 review worktree 未安装 docs-site 的 rspress/node_modules;与先前 review 环境限制一致,不计新 finding。

修完 P1 后我继续复审。申晗确认前仍不要合码。

codex 复审抓到 P1(我已独立写 repro 复现):parseCodexTokenBreakdown 对
total / last 各自独立把缺失的 cacheWriteInputTokens 补 0,检测不到两边的
presence 不对称。当首包 total 带 cacheWrite=40、last 恰好缺该字段时,累加器
不 poison 反而正常发出错误四桶——得到 {input:60,cacheCreate:0},正确应为
{input:20,cacheCreate:40}:40 个 cache-create 被误算进 fresh input,且后续
valid 包会沿错误 baseline 少算首个 completion。无 poison、无 warning,属于
「可信外观的错值」,正违背本 PR 自己的 fail-closed 原则。

修复:
- 新增 parseTokenUsagePair(rawTotal, rawLast)——在配对处强制 total 与 last 的
  cacheWrite presence 对称:只有两边同时缺失才兼容为 0(真·老版本 codex),
  只缺一边即判协议不一致返 null → runner poison 该 turn(不 fabricate 错值)。
  runner 的 handleNotification 改用 pair 解析,非对称即 sticky poison。
- 顺手补 codex 提的非阻塞项:accumulator.result() 在因 delta 负值 / bucket
  split 不一致而 omit 时,写入 protocolWarning(runner 已有 warning 落 log),
  不再静默丢弃。

测试(token-usage 相关 6 文件 106 测绿,+8):
- parseTokenUsagePair 5 例:良构 / 两边同缺(0/0)/ 两向非对称拒绝 / 任一
  malformed。
- accumulator 端到端:非对称首包 poison → later-valid 不能复活 → omit。
- bucket-split omit 写 warning 断言。
- fixture 加 FAKE_TOKEN_USAGE_ASYM;integration 真 runner 断言 usage omit。
- mutation 验证有牙:禁用对称 guard 恰好这 3 个 P1 测试转红。
build 绿。

Co-Authored-By: Claude <noreply@anthropic.com>
@deepcoldy

Copy link
Copy Markdown
Owner Author

修复 codex 复审 P1 —— cacheWrite 非对称缺失致误算(commit e6d52c0

@codex 复审抓的 P1 成立,我先独立写了 repro 在真实代码上复现,再修。

复现(修前)

首包 totalcacheWriteInputTokens=40last 恰好缺该字段(各自被独立补 0)时,累加器不 poison反而正常发出错误四桶:

inputTokens cacheCreateTokens
修前(错) 60 0
正确 20 40

40 个 cache-create 被误算进 fresh input;且后续 valid 包会沿错误 baseline 少算首个 completion。无 poison、无 warning——「可信外观的错值」,正违背本 PR 自己的 fail-closed 原则。

根因parseCodexTokenBreakdowntotal / last 各自独立把缺失 cacheWrite 补 0,看不到两边的 presence 不对称。

修法(按 codex spec)

  1. 新增 parseTokenUsagePair(rawTotal, rawLast)——在配对处强制 cacheWrite presence 对称:只有 total 与 last 同时缺失才兼容为 0(真·老版本 codex);只缺一边即返 null → runner poison 该 turn,不 fabricate 错值。runner 的 handleNotification 改用 pair 解析(原先分别 parseCodexTokenBreakdownif(total&&last))。
  2. 非阻塞项也一并修accumulator.result() 因 delta 负值 / bucket-split 不一致而 omit 时,写入 protocolWarning(runner 已有 if(acc?.warning && !usage) 落 log),不再静默丢弃。

测试(token-usage 相关 6 文件 106 测绿,+8)

  • parseTokenUsagePair 5 例:良构 / 两边同缺(0/0) / 两向非对称拒绝 / 任一 malformed。
  • accumulator 端到端:非对称首包 poison → later-valid 不能复活 → omit
  • bucket-split omit 写 warning 断言。
  • fixture 加 FAKE_TOKEN_USAGE_ASYM;integration 真 runner + fake app-server 断言 final.usage omit。
  • mutation 验证有牙:临时禁用对称 guard,恰好这 3 个 P1 测试转红,恢复即绿。

pnpm build ✅;git merge-tree vs origin/master 无冲突diff --check 干净。最终 SHA:e6d52c0

@codex 请继续复审。申晗确认前不合码。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

终审 e6d52c0fP1 已修复,未发现新的 blocking finding;代码层面可以合并。 同账号无法点 APPROVE,故以 COMMENT 记录通过;本轮没有执行合并,仍按约定等待申晗确认。

P1 复核

  • parseTokenUsagePair(total, last) 已成为 runner 的生产入口;只有两边同时缺失 cacheWriteInputTokens 才按旧版协议兼容为 0/0。
  • total 有 / last 缺与反向两种不对称都会返回 null;runner 随即 poison 已知 turnId,不会建立错误 baseline。
  • asymmetric 首包之后再来 valid 包,poison 仍保持 sticky,final marker 继续 omit usage;我手工重跑 repro 也得到 {first:null,result:null,warning:"malformed"}
  • 两边同缺、两向不对称、任一 breakdown malformed、later-valid 不复活均有 unit 覆盖;真 runner + fake app-server 也锁住了 asymmetric → valid → final omit。
  • 上轮非阻塞项已收口:bucket split 不一致时 result() 会写 protocolWarning,runner 的既有 warning 日志路径可见,不再静默。

回归面

修复只改 codex-app 的 notification parser / accumulator 与对应 fixture/tests;worker→daemon→async store→trigger-result 传递链、其它 CLI、Wait Mode、普通 Lark 回复、PTY/Tmux 共用路径都没有新增改动。pair parser 目前也只有 codex-app runner 一个生产调用点,不存在仍绕过 pair invariant 的旧入口。

实际验证

  • pnpm build
  • 定向 6 files / 106 tests ✅(token usage 25、protocol 23、state 24、store 19、glue 3、runner integration 12)
  • git diff --check upstream/master...HEAD
  • 最新 upstream/master = 9819a666;merge-tree 无冲突,临时合并 e6d52c0f + master 后再跑 pnpm build
  • GitHub build + CodeQL 全绿 ✅

仅剩非阻塞 metadata:PR body 的“98 测绿 / 最终 SHA b39f0e6”已经过期,合并前建议同步为 106 / e6d52c0,并补一句 pair-presence 修复;过程 comment 已经是最新,不影响代码结论。

结论:no blocking issues at e6d52c0。申晗确认前不要合码。

@deepcoldy
deepcoldy merged commit 9a77f26 into master Jul 29, 2026
5 of 6 checks passed
@deepcoldy

Copy link
Copy Markdown
Owner Author

✅ 已合并(申晗确认)

申晗确认后 admin-merge(fork PR 无 CI,REST 端点 pinned sha=e6d52c0f)。merge commit 9a77f26,已入 master。

双审收敛回顾:Claude 首审🟢 → codex 复审抓 1 P1(cacheWrite 非对称缺失误算)→ 我 repro 证实后代修 e6d52c0f(parseTokenUsagePair 强制 total/last presence 对称 + incoherent omit warning)→ codex 终审 ✅ APPROVE。106 测绿(mutation 验证有牙)、build 绿、merge 无冲突、PR body 已同步。

⚠️ 未发版;live 生效(仅 codex-app,riff 任务详情展示)需 pnpm switch:here && pnpm daemon:restart 后真机验证。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant