Skip to content

fix(usage): 无可用窗口的账号用量快照不再覆盖持久化的有效数据 - #2467

Open
AwakeyDonkey wants to merge 7 commits into
makecindy:mainfrom
AwakeyDonkey:fix/usage-empty-snapshot-guard
Open

fix(usage): 无可用窗口的账号用量快照不再覆盖持久化的有效数据#2467
AwakeyDonkey wants to merge 7 commits into
makecindy:mainfrom
AwakeyDonkey:fix/usage-empty-snapshot-guard

Conversation

@AwakeyDonkey

@AwakeyDonkey AwakeyDonkey commented Aug 11, 2026

Copy link
Copy Markdown

这次改了什么

摘要

account_usage_snapshots 的两个写入点(Codex 账号用量 / Claude 订阅用量)会在内存缓存为空时把 merge 出来的空壳快照写回库里,覆盖掉行里原有的有效数据,且不可恢复。

内存为空有两条路径:

  1. 冷缓存 hydration 读库失败,或 owner 尚未初始化导致 hydration 被跳过;
  2. codex 侧的并发竞态 —— ensureCodexAccountUsageLoadedcodexAccountUsageLoaded 置位放在 await 之前,并发的第二笔 record 会立刻返回并在空内存上 merge(claude 侧用 load-promise 防住了这个,codex 侧没有)。

任一路径下,windowless 稀疏事件(app-server 滚动更新契约里的合法形态)都失去了 merge 所依赖的旧值,产出结构完整但值全为 null 的 payload 并落库。实测(2026-08-11,正式版):codex 行从含 primary 窗口 / credits / planType 的完整快照被改写成空壳,对消费方是静默失败(JSON 可解析、字段都在、值全空)。

修法是把落库判据放在"来源"而不是"内容"上:只有 hydration 确实读到过库(读到空行也算,那说明库里本来就没有)才允许写回。同时给 codex 侧补上 load-promise 串行化,从根上消除产生空壳 payload 的路径。

判据不能基于"payload 看起来是否有用"——合法的空与事故的空形状完全一致:清除 reached 标记、只有 plan/credits 的 WHAM 快照、rejected → allowed 的状态转换都会被按内容的判据误伤(本 PR 前两版即如此,感谢 review 指出)。而只要 merge 的底子可信,既有的 merge 逻辑本身就已经正确保住了窗口,不需要任何内容启发式。

变更类型

  • feat 新功能
  • fix 缺陷修复
  • refactor / perf 重构或性能优化
  • docs / test / chore 文档、测试或工程维护
  • 其他:

范围

  • 关联 Issue / 需求:无(可复现的数据损坏 bug,直接修复)
  • 本 PR 包含:两个写入点基于 hydration 的落库守卫;codex 侧 hydration 的 load-promise 串行化;9 个测试用例
  • 明确不包含:codex 侧 hydration 缺少 claude 那样的世代(generation)计数——clear 抢在 in-flight hydration 之前时,读回的旧行仍可能复活内存状态。这是既有行为,本 PR 未改变它,留待单独处理
  • 用户可见变化:额度悬浮卡 / chip 不再因一次失败的刷新或启动期的并发而永久丢失已有额度数据
  • 是否存在 breaking change:无

UI 变化

不涉及。

  • 引用的设计规范:不涉及

怎么验证的

自动验证

(环境:Node 22.22.3 / pnpm 10.33.2,main @ 46c6c32e8 基础上)

1. 新增用例先确认是真回归测试 —— 暂存源码改动后单独跑测试:
   git stash push apps/desktop/src/main/usageBroadcaster.ts
   npx vitest run src/main/__tests__/usageBroadcasterCodexAccount.test.ts \
                  src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts
   结果:4 failed | 32 passed —— 4 个新用例(限额解除、credits-only web 快照、
   rejected→allowed 转换、并发时广播空快照)全部如预期失败

2. 恢复改动后,同一命令:
   结果:36/36 passed(原有 32 个用例全部保持通过)

3. pnpm --filter desktop run typecheck
   结果:通过

4. pnpm test:unit(仓库根,全量)
   结果:退出码 0,零失败,apps/desktop unit PASS(127.7s)

手工验证

不涉及。修复位于 main 侧,测试不需要 DB / Electron;未启动 Electron。

未执行的验证

  • 未在真机上复现原始损坏场景(需要构造"hydration 失败或并发抢跑 + windowless 事件"的时序,已由单测等价覆盖)
  • 未跑 pnpm test:all:改动限于单个模块,风险面与 unit tier 匹配

风险

风险分类

  • 无已知风险
  • SQLite / migration
  • system prompt
  • 协议兼容
  • 权限 / 安全 / 用户数据
  • 存量插件兼容(批准状态 / 指纹 / manifest 校验 / 安装布局 / 包格式)
  • 原生层 / fingerprint / OTA
  • 跨平台差异
  • 其他:

(说明:改动触及 account_usage_snapshots 的写入路径与 codex 侧 hydration 的时序,但不改 schema、不加 migration、不改任何接口签名。)

影响与回滚

  • 影响范围:apps/desktop/src/main/usageBroadcaster.ts 的两个落库函数与 codex hydration;行为变化是"hydration 尚未成功时不写回"(内存态与广播不变,下一次刷新自然补上)与"并发 record 串行等待同一次读"
  • 回滚 / 降级方式:revert 本分支的提交即回到原行为,无数据迁移、无状态残留

提交前检查

  • 已 review 完整 diff
  • 每个 commit 都带 DCO 签名(git commit -s,见 DCO
  • UI 改动已在「UI 变化」注明引用的设计规范章节(不涉及 UI 则跳过)
  • 未提交凭证、令牌或授权文件
  • 已补充必要文档
  • 已确认测试结果或说明未执行原因

🤖 Generated with Claude Code

@AwakeyDonkey
AwakeyDonkey requested a review from a team as a code owner August 11, 2026 13:02

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 49e23177bd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/desktop/src/main/usageBroadcaster.ts Outdated
@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown

Greptile Summary

此 PR 修复 Codex 账号用量与 Claude 订阅用量在 hydration 未成功时覆盖持久化有效快照的问题。

  • 仅在数据库 hydration 成功后允许写回用量快照
  • 为 Codex hydration 增加共享 load promise,避免并发记录在空缓存上提前合并
  • hydration 重试成功后,将失败期间收到的内存更新合并到持久化快照
  • 新增回归测试,覆盖读库失败、owner 延迟初始化、稀疏事件、状态转换及并发 hydration

Confidence Score: 5/5

当前变更看起来可以安全合并。

未发现仍然存在的阻塞性故障。

Important Files Changed

Filename Overview
apps/desktop/src/main/usageBroadcaster.ts 为 Codex 与 Claude 用量写入增加 hydration 成功守卫,并补齐 Codex 并发加载串行化及失败重试合并。
apps/desktop/src/main/tests/usageBroadcasterCodexAccount.test.ts 新增 Codex 持久化防覆盖、瞬时读库失败恢复、合法无窗口状态及并发 hydration 的回归测试。
apps/desktop/src/main/tests/usageBroadcasterClaudeSubscription.test.ts 新增 Claude hydration 失败保护、恢复合并、owner 延迟初始化及状态转换持久化测试。

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Event[收到用量快照] --> Ensure[执行 hydration]
  Ensure -->|读取失败或 owner 缺失| MergeMemory[仅合并并广播内存状态]
  MergeMemory --> Skip[跳过持久化并保留重试机会]
  Ensure -->|读取成功或确认无行| MergePersisted[以持久化快照为底合并内存更新]
  MergePersisted --> Broadcast[广播合并后的快照]
  Broadcast --> Upsert[写回 account_usage_snapshots]
  Skip --> Retry[下一次 record/read 重试 hydration]
Loading

Reviews (7): Last reviewed commit: "fix(usage): clear the load promise after..." | Re-trigger Greptile

@MagicLizi

Copy link
Copy Markdown
Contributor

@AwakeyDonkey 👋 这个 PR 还有 1 条 review conversation 没 resolve(apps/desktop/src/main/usageBroadcaster.ts),auto-review 因此暂时跳过、没法继续审查 / 合并。

如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。

@MagicLizi MagicLizi added the touches:core 改动碰到架构核心路径(review-pr 自动维护,仅展示) label Aug 11, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5ecbdbebb8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/desktop/src/main/usageBroadcaster.ts Outdated
Comment thread apps/desktop/src/main/usageBroadcaster.ts Outdated
Comment thread apps/desktop/src/main/usageBroadcaster.ts Outdated
@MagicLizi

Copy link
Copy Markdown
Contributor

@AwakeyDonkey 👋 这个 PR 还有 2 条 review conversation 没 resolve(apps/desktop/src/main/usageBroadcaster.ts),auto-review 因此暂时跳过、没法继续审查 / 合并。

如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 68ccf51c03

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/desktop/src/main/usageBroadcaster.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c09edd59a7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/desktop/src/main/usageBroadcaster.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 43634dd678

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/desktop/src/main/usageBroadcaster.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 099384bb10

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/desktop/src/main/usageBroadcaster.ts
Comment thread apps/desktop/src/main/usageBroadcaster.ts
@MagicLizi

Copy link
Copy Markdown
Contributor

@AwakeyDonkey 👋 这个 PR 还有 3 条 review conversation 没 resolve(apps/desktop/src/main/usageBroadcaster.ts),auto-review 因此暂时跳过、没法继续审查 / 合并。

如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。

@MagicLizi MagicLizi added the awaiting-discussion 等待维护者讨论(review-pr) label Aug 11, 2026
@MagicLizi

Copy link
Copy Markdown
Contributor

维护者确认

此 PR 触发了架构维护者确认门(arch: core-paths,核心路径改动 505 行 ≥ 150):修改了 apps/desktop/src/main/usageBroadcaster.ts 中 account_usage_snapshots 的写入全路径与 codex hydration 时序。

已自动创建讨论 issue 供维护者跟踪。

当前状态

需维护者在 PR 上确认后方可合并:

  • 同意合并 → 直接在 PR 上点 Approve
  • 需要修改 → 点 Request Changes 并写明修改要求,作者改完后重新 Approve 即放行

讨论 issue:#2470

AwakeyDonkey and others added 7 commits August 12, 2026 03:42
account_usage_snapshots 的两个写入点(codex / claude-code)在内存缓存为空时
(冷缓存 hydration 读库失败 / 启动早期 owner 未初始化被跳过), windowless 稀疏
事件无旧值可兜, 全 null payload 会原样 upsert, 把持久化行里的有效窗口 /
credits / planType 永久抹掉, 且对消费方是静默失败(2026-08-11 实测复现)。

落库前检查 payload 是否带任何可用窗口(窗口非空且 usedPercent / utilization
为有限数), 没有则跳过 upsert、保留旧行、记 warn 日志。内存态与广播行为不变;
正常路径下 windowless 稀疏更新经 merge 兜住窗口后照常落库(有回归用例守护)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com>
前一个 commit 的守卫只认「有可用窗口」, 会把带 rateLimitReachedType 的
windowless 快照一并拦下。但这种形态是正常的(如 credits 耗尽), 且
isCodexWindowlessFallback 明确把 reached 标记当权威值 —— merge 会正当地
把旧窗口清成 null, 于是每一次真实的限额耗尽事件都不再落库。

后果: goal-host 的 getAccountLimit 从持久化的 rateLimitReachedType 判
limited, 重启后读回的旧行没有该标记, 暂停的目标会直接重新撞进同一个限额。

守卫改为「有可用窗口 或 有权威的已达限额标记」。claude 侧对称处理
rateLimitStatus='rejected'(isClaudeSubscriptionAlerting 据此告警)。
原有的全 null 空壳仍然被拦(实测损坏行的 rateLimitReachedType 为 null,
两个判据不重叠)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com>
The previous guards asked whether the merged payload looked useful. That
predicate cannot separate a legitimate empty state from a corrupted one:
clearing a reached marker, a credits-only WHAM snapshot and a rejected ->
allowed transition all produce shapes indistinguishable from the failure
being guarded against, so each was silently dropped.

Gate on provenance instead: persist only once hydration has actually read
the row (an absent row counts - the database state is then known). With a
trustworthy merge base the existing merge logic already preserves windows
for sparse windowless events, so no content heuristic is needed.

Also serialize concurrent records behind one hydration read on the codex
side, mirroring the claude side. Setting the loaded flag before the await
let a second concurrent record merge onto empty memory and broadcast an
empty snapshot before hydration overwrote it - the path that produces the
all-null payload this PR started from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com>
The previous commit marked the cache as loaded in the finally block, so a
transient read failure short-circuited every later hydration attempt while
leaving the hydrated flag false. The new persistence guard then skipped
every write for the rest of the process: one db-busy error was enough to
stop persisting usage entirely until restart.

Having a separate loaded flag is what allowed that contradictory state to
exist - loaded, but with no trustworthy base. Collapse the two into one:
the hydrated flag already means both "we have a trustworthy base" and
"no need to read again". A failed read leaves it false, so the next
record or read retries; clear sets it, since the database state is then
known.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com>
Retrying a failed hydration reintroduced a loss the retry was meant to
prevent. Snapshots that arrive while the read is failing live only in
memory, since the guard keeps them out of the database; when a later read
finally succeeds it assigned the persisted slots wholesale, so the older
row replaced those newer observations. The UI fell back to stale quota and
the observations never got a chance to persist.

Hydration now seeds only what memory does not already hold: the codex
buckets are layered under the in-memory ones, and the claude path runs the
persisted row through the normal merge with the in-memory snapshot as the
incoming side. The latter also recovers endpoint-only fields (scoped,
extraUsage) that a headers-sourced snapshot cannot carry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com>
Layering the in-memory buckets over the persisted ones by key let a sparse
bucket win wholesale. A windowless event received while the read was
failing leaves a non-empty but all-null bucket under the same limitId, so
the key-level override dropped the persisted primary/secondary windows and
wrote that loss back on the next event - the exact corruption this PR set
out to prevent, in a new disguise.

Merge each pending bucket onto its persisted counterpart through
mergeCodexAccountUsageSnapshot instead, so a windowless bucket keeps the
known windows by the existing rules. The web slot goes through the same
merge rather than a null-coalesce.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com>
When getCurrentUserId() is not available yet the async IIFE runs to
completion before its first await, so the finally block cleared the handle
synchronously and the outer assignment immediately wrote the resolved
promise back. Every later ensure call then reused that settled promise and
never queried the database again, leaving hydrated false for the lifetime
of the process - so the new persistence guard skipped every write.

Clear the handle after awaiting it instead. The claude path had the same
shape and is fixed the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com>
@AwakeyDonkey
AwakeyDonkey force-pushed the fix/usage-empty-snapshot-guard branch from 099384b to e313ae6 Compare August 11, 2026 15:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-discussion 等待维护者讨论(review-pr) touches:core 改动碰到架构核心路径(review-pr 自动维护,仅展示)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants