fix(usage): 无可用窗口的账号用量快照不再覆盖持久化的有效数据 - #2467
Conversation
There was a problem hiding this comment.
💡 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".
|
| 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]
Reviews (7): Last reviewed commit: "fix(usage): clear the load promise after..." | Re-trigger Greptile
|
@AwakeyDonkey 👋 这个 PR 还有 1 条 review conversation 没 resolve(apps/desktop/src/main/usageBroadcaster.ts),auto-review 因此暂时跳过、没法继续审查 / 合并。 如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。 |
There was a problem hiding this comment.
💡 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".
|
@AwakeyDonkey 👋 这个 PR 还有 2 条 review conversation 没 resolve(apps/desktop/src/main/usageBroadcaster.ts),auto-review 因此暂时跳过、没法继续审查 / 合并。 如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。 |
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
💡 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".
|
@AwakeyDonkey 👋 这个 PR 还有 3 条 review conversation 没 resolve(apps/desktop/src/main/usageBroadcaster.ts),auto-review 因此暂时跳过、没法继续审查 / 合并。 如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。 |
维护者确认此 PR 触发了架构维护者确认门(arch: core-paths,核心路径改动 505 行 ≥ 150):修改了 已自动创建讨论 issue 供维护者跟踪。 当前状态需维护者在 PR 上确认后方可合并:
讨论 issue:#2470 |
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>
099384b to
e313ae6
Compare
这次改了什么
摘要
account_usage_snapshots的两个写入点(Codex 账号用量 / Claude 订阅用量)会在内存缓存为空时把 merge 出来的空壳快照写回库里,覆盖掉行里原有的有效数据,且不可恢复。内存为空有两条路径:
ensureCodexAccountUsageLoaded把codexAccountUsageLoaded置位放在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文档、测试或工程维护范围
clear抢在 in-flight hydration 之前时,读回的旧行仍可能复活内存状态。这是既有行为,本 PR 未改变它,留待单独处理UI 变化
不涉及。
怎么验证的
自动验证
手工验证
不涉及。修复位于 main 侧,测试不需要 DB / Electron;未启动 Electron。
未执行的验证
pnpm test:all:改动限于单个模块,风险面与 unit tier 匹配风险
风险分类
(说明:改动触及
account_usage_snapshots的写入路径与 codex 侧 hydration 的时序,但不改 schema、不加 migration、不改任何接口签名。)影响与回滚
apps/desktop/src/main/usageBroadcaster.ts的两个落库函数与 codex hydration;行为变化是"hydration 尚未成功时不写回"(内存态与广播不变,下一次刷新自然补上)与"并发 record 串行等待同一次读"提交前检查
git commit -s,见 DCO)🤖 Generated with Claude Code