feat(trigger): codex-app per-turn token usage → trigger-result(PR B / 拆自 #638) - #657
Conversation
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
left a comment
There was a problem hiding this comment.
Review 3cacaad0。happy path 传递链方向正确,total-delta 也比取 last / 累加 last 正确;本地额外跑了 7 个相关测试文件共 155 tests、pnpm build、git diff --check,以及 GitHub build/CodeQL,均通过。
但当前有 3 组 blocking finding:
-
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 ThreadTokenUsage、TokenUsageInfo::append_last_usage。 -
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。 -
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 不在本轮范围。
| 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; } |
There was a problem hiding this comment.
P1: 这里把所有缺失字段都当 0 是 fail-open。0.145 的 breakdown 中 totalTokens/inputTokens/cachedInputTokens/outputTokens/reasoningOutputTokens 都是必填 number;只有 cacheWriteInputTokens 有 serde default 用于兼容。比如缺 cachedInputTokens 会把缓存读静默算成 fresh input,而不是 omit。建议只允许 cacheWrite 缺失→0,其它字段必须存在、为非负整数,否则整条 usage 无效并告警。
| 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); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
这个 IPC 边界只校验 finite,-1 / 0.5 会穿过 worker、落盘并公开返回。token bucket 应为 non-negative integer;请在 normalizer 也按四字段全量校验,防未来 runner regression 或脏 marker 绕过 accumulator 的不变量,并补 valid/partial/negative/fractional 测试。
| * 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>(); |
There was a problem hiding this comment.
注释与生命周期不符: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
left a comment
There was a problem hiding this comment.
复审 70d0e3c4:上一轮关于负 baseline、全字段 monotonic、required-field 解析、warning 输出、bounded map、daemon→store 持久化和双语文档缺失的主体修改都看到了;定向 8 files / 164 tests、pnpm build、git diff --check 以及 GitHub CI 均通过。
但这版还不能给绿,仍有 3 个 blocking finding + 1 个公开文档范围错误:
-
P1 · final marker 的外部边界仍接受负数/小数,和本轮宣称的 fail-closed 契约不一致。
src/services/codex-app-runner-protocol.ts:160-168的normalizeFinalUsage仍只检查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,没覆盖你消息里声称已拒绝的两类。
-
P1 · malformed 通知不是 sticky fail-closed,下一条合法通知会“恢复”为少算的部分 usage。
src/codex-app-runner.ts:343-359对total/lastparse 失败只是静默跳过、连 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 通知至少也应告警。 -
P1 · 新 glue test 还没有穿过真实
trigger-resultlookup,live API 组合层仍无回归保护。
test/async-usage-glue.test.ts:65-69只断言ds.asyncTriggerResults和asyncTriggerStore.lookup();后者是直接读 store,不是 daemon restart 后的GET /api/sessions/:id/trigger-result。因此删掉src/core/dashboard-ipc-server.ts:908的memResult.usagespread,这个所谓 end-to-end/restart test 仍会全绿;纯 state test 也只直接构造 resolver input,保护不到这个组合点。请补真实 IPC route(或等价地触达
buildAsyncTriggerLookupResponse)的两条断言:live mem completed 返回 usage;无 live session、仅磁盘记录时返回 usage。这样才真正锁住 daemon→lookup response 的内存和重启两条路径。 -
P2 · 双语公开文档把实现范围扩大错了。
中英文第 158 行写“codex / codex-app tasks”,但本 PR 的采集器只在codex-app-runner.ts,纯codexfinal_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
left a comment
There was a problem hiding this comment.
终审 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>
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、只在 为什么算法要「total-delta」而不是直接取 last(全 PR 核心)token 不在 我实测验证的关键点(不是只读 diff)本机有真的 codex 0.145.0,我用
影响范围核查(CLAUDE.md 要求)
本地验证
非阻塞观察(供参考,不要求改)
@codex 请复审,重点可放在:total-delta 在 resume/fold-in(复用既有 thread)场景的 baseline 推导、以及 poison 的 sticky 语义边界。 |
|
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; |
There was a problem hiding this comment.
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。
There was a problem hiding this comment.
复核 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
left a comment
There was a problem hiding this comment.
复审 b39f0e64:total-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>
修复 codex 复审 P1 —— cacheWrite 非对称缺失致误算(commit e6d52c0)@codex 复审抓的 P1 成立,我先独立写了 repro 在真实代码上复现,再修。 复现(修前)首包
40 个 cache-create 被误算进 fresh input;且后续 valid 包会沿错误 baseline 少算首个 completion。无 poison、无 warning——「可信外观的错值」,正违背本 PR 自己的 fail-closed 原则。 根因: 修法(按 codex spec)
测试(token-usage 相关 6 文件 106 测绿,+8)
@codex 请继续复审。申晗确认前不合码。 |
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
终审 e6d52c0f:P1 已修复,未发现新的 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。申晗确认前不要合码。
✅ 已合并(申晗确认)申晗确认后 admin-merge(fork PR 无 CI,REST 端点 pinned 双审收敛回顾:Claude 首审🟢 → codex 复审抓 1 P1(cacheWrite 非对称缺失误算)→ 我 repro 证实后代修
|
背景
拆自 #638(superseded)的 per-trigger usage 块。异步任务完成后,
trigger-result的completed态带上这一轮真实消耗的 token(四桶:input/output/cacheRead/cacheCreate),供 riff 任务详情展示。仅 codex-app(走 app-server 结构化协议、有 thread/tokenUsage/updated 通知)。契约与算法按 codex 用 Codex 0.145 生成类型 + 官方
append_last_usage源码交底实现。为什么不是"取 last"
token 来自独立通知
thread/tokenUsage/updated(turn/completed的 Turn 无 usage)。一个 codex turn 有多次 completion,tokenUsage.last只是最后一次的量。本轮用 total-delta:首条baseline=total-last,之后本轮=latestTotal-baseline(逐字段);重复通知幂等。fail-closed(codex 三轮 review 加固)
parseTokenUsagePair强制 total/last 的cacheWriteInputTokenspresence 对称——只有两边同时缺失才兼容为 0(真·老版本 codex),只缺一边即返 null → runner poison 该 turn(否则 0-default 会把 cache-create 误算进 fresh input,且后续 valid 包沿错误 baseline 少算)。result()因 delta 负值 / bucket-split 不一致 omit 时也写 warning(不再静默);usage map 有界清理(未 finalize 的 turn 不泄漏)。传递链
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 测绿)
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