From fd38fd25994a1d4c91cbe37ab9f99e53489fb9b8 Mon Sep 17 00:00:00 2001 From: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:47:09 +1200 Subject: [PATCH 1/7] =?UTF-8?q?fix(usage):=20=E6=97=A0=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E7=9A=84=E8=B4=A6=E5=8F=B7=E7=94=A8=E9=87=8F?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E4=B8=8D=E5=86=8D=E8=A6=86=E7=9B=96=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E7=9A=84=E6=9C=89=E6=95=88=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com> --- ...usageBroadcasterClaudeSubscription.test.ts | 43 ++++++++++++++ .../usageBroadcasterCodexAccount.test.ts | 59 +++++++++++++++++++ apps/desktop/src/main/usageBroadcaster.ts | 50 ++++++++++++++++ 3 files changed, 152 insertions(+) diff --git a/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts b/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts index d07028e1d5..a8f67872ca 100644 --- a/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts +++ b/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts @@ -88,6 +88,49 @@ describe('claude subscription snapshot hydration race', () => { expect(current?.updatedAt).toBe(2); }); + it('does not clobber the persisted row with a window-less snapshot when hydration failed', async () => { + const broadcaster = await import('../usageBroadcaster'); + // 冷缓存 hydration 读库失败 → 内存为空; 一笔 status-only headers 快照 + // (仅 rateLimitStatus, 无任何窗口)到达。merge 无旧值可保 → 全空快照会被 + // 无条件 upsert, 抹掉持久化行里的有效窗口(与 codex 侧同形状的覆盖事故)。 + mocks.queryOne.mockRejectedValue(new Error('db busy')); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: null, + sevenDay: null, + rateLimitStatus: 'allowed', + source: 'unified-headers', + updatedAt: 5, + }); + + expect(mocks.exec).not.toHaveBeenCalled(); + }); + + it('persists a status-only snapshot merged onto hydrated windows (regression guard)', async () => { + const broadcaster = await import('../usageBroadcaster'); + // hydration 正常命中 → status-only 增量并入已有窗口, 照常落库且窗口保留。 + mocks.queryOne.mockResolvedValue({ + snapshot: JSON.stringify({ + fiveHour: { utilization: 54, resetsAt: 1_786_355_999 }, + source: 'oauth-endpoint', + updatedAt: 1, + }), + }); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: null, + sevenDay: null, + rateLimitStatus: 'allowed', + source: 'unified-headers', + updatedAt: 5, + }); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.fiveHour?.utilization).toBe(54); + }); + it('discards an in-flight hydration result when clear wins the race', async () => { const broadcaster = await import('../usageBroadcaster'); const dbRead = deferred<{ snapshot: string } | null>(); diff --git a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts index 7da2e445ed..60c982a807 100644 --- a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts +++ b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts @@ -384,6 +384,65 @@ describe('codex stale bucket pruning', () => { }); }); +describe('empty snapshot must not clobber the persisted row', () => { + beforeEach(() => { + vi.resetModules(); + mocks.queryOne.mockReset().mockResolvedValue(null); + mocks.exec.mockReset().mockResolvedValue(undefined); + mocks.getCurrentUserId.mockReturnValue('user-1'); + }); + + // 2026-08-11 用户实报的真实覆盖事故: 一条全 null 的 windowless app-server 事件 + // 落在**空的内存缓存**上(hydration 未命中), merge 无旧值可保, 全 null 桶被 + // 无条件 upsert 落库 —— 持久化行里的有效窗口 / credits / planType 永久丢失, + // 且对消费方是静默失败(JSON 可解析、字段都在、值全 null)。 + const NULL_SPARSE_EVENT = { + limitId: 'codex', + limitName: null, + primary: null, + secondary: null, + credits: null, + planType: null, + rateLimitReachedType: null, + source: 'codex-app-server', + }; + + it('skips persistence when hydration failed and the event carries no usable window', async () => { + const broadcaster = await import('../usageBroadcaster'); + // 冷缓存 hydration 读库失败(db busy 等) → 内存为空, 但持久化行还躺着好数据。 + mocks.queryOne.mockRejectedValue(new Error('db busy')); + + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + + // 全 null payload 不得 upsert —— 否则库里的有效行被抹掉且不可恢复。 + expect(mocks.exec).not.toHaveBeenCalled(); + }); + + it('skips persistence when the owner is not initialized yet', async () => { + const broadcaster = await import('../usageBroadcaster'); + // 启动早期 getCurrentUserId 尚不可用 → hydration 被跳过, 内存为空。 + mocks.getCurrentUserId.mockReturnValue(null as unknown as string); + + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + + expect(mocks.exec).not.toHaveBeenCalled(); + }); + + it('still persists windowless events merged onto hydrated windows (regression guard)', async () => { + const broadcaster = await import('../usageBroadcaster'); + // hydration 正常命中: windowless 稀疏事件按契约并入已有桶, 窗口保留 → 照常落库。 + mocks.queryOne.mockResolvedValue({ snapshot: JSON.stringify(APP_SERVER_SNAPSHOT) }); + + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.primary?.usedPercent).toBe(82); + expect(persisted.secondary?.usedPercent).toBe(55); + }); +}); + describe('sparse rate-limit updates without a limitId', () => { beforeEach(() => { vi.resetModules(); diff --git a/apps/desktop/src/main/usageBroadcaster.ts b/apps/desktop/src/main/usageBroadcaster.ts index 16a2c1d35d..54108592be 100644 --- a/apps/desktop/src/main/usageBroadcaster.ts +++ b/apps/desktop/src/main/usageBroadcaster.ts @@ -541,6 +541,30 @@ function isCodexWindowlessFallback(snapshot: RateLimitSnapshot): boolean { return !snapshot.primary && !snapshot.secondary; } +/** 窗口可用 = 非空且 usedPercent 是有限数(RateLimitWindow 的必填字段)。 */ +function hasUsableCodexWindow(snapshot: RateLimitSnapshot | null | undefined): boolean { + return [snapshot?.primary, snapshot?.secondary].some( + (window) => Boolean(window) && Number.isFinite(window?.usedPercent), + ); +} + +/** + * payload 是否值得落库: 任一槽(顶层 / 桶表 / web)带可用窗口。 + * + * windowless 稀疏事件本身是合法的(app-server 滚动更新契约), merge 靠内存旧值兜住 + * 窗口 —— 但内存为空时(hydration 读库失败 / owner 未初始化被跳过)无值可兜, 全 null + * payload 会原样 upsert, 把持久化行里的有效数据永久抹掉(2026-08-11 用户实报)。 + * 空 payload 不落库: 保留旧行, 重启后 hydration 仍能读回有效数据。 + */ +function hasPersistableCodexAccountUsage(payload: CodexAccountUsagePayload | null): boolean { + if (!payload) return false; + return ( + hasUsableCodexWindow(payload) + || hasUsableCodexWindow(payload.webSnapshot) + || Object.values(payload.appServerBuckets ?? {}).some(hasUsableCodexWindow) + ); +} + async function ensureCodexAccountUsageLoaded(): Promise { resetCodexAccountUsageCacheIfOwnerChanged(); if (codexAccountUsageLoaded) return; @@ -607,6 +631,12 @@ export async function recordCodexAccountUsageSnapshot(snapshot: unknown): Promis const payload = buildCodexAccountUsagePayload(); broadcastCodexAccountUsage(payload); + // 无可用窗口的 payload 不 upsert(保留旧行), 见 hasPersistableCodexAccountUsage。 + if (!hasPersistableCodexAccountUsage(payload)) { + log.warn('skip persisting codex account usage snapshot without any usable window'); + return; + } + try { await getDbClient().exec( `INSERT INTO account_usage_snapshots (agent_kind, snapshot, updated_at) @@ -748,6 +778,19 @@ async function ensureClaudeSubscriptionUsageLoaded(): Promise { await claudeSubscriptionUsageLoadPromise; } +/** 与 codex 侧 hasUsableCodexWindow 同口径: 窗口非空且 utilization 是有限数。 */ +function hasPersistableClaudeSubscriptionUsage( + snapshot: ClaudeSubscriptionUsageSnapshot, +): boolean { + const usable = (window: { utilization?: number } | null | undefined): boolean => + Boolean(window) && Number.isFinite(window?.utilization); + return ( + usable(snapshot.fiveHour) + || usable(snapshot.sevenDay) + || (snapshot.scoped?.some(usable) ?? false) + ); +} + export async function recordClaudeSubscriptionUsageSnapshot(snapshot: unknown): Promise { if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) return; @@ -765,6 +808,13 @@ export async function recordClaudeSubscriptionUsageSnapshot(snapshot: unknown): claudeSubscriptionUsageSnapshot = next; broadcastClaudeSubscriptionUsage(next); + // 与 codex 侧同一条保护: status-only 增量落在空内存缓存上(hydration 读库失败)时, + // merge 无旧值可保, 全空快照不得 upsert 抹掉持久化行里的有效窗口。 + if (!hasPersistableClaudeSubscriptionUsage(next)) { + log.warn('skip persisting claude subscription usage snapshot without any usable window'); + return; + } + try { await getDbClient().exec( `INSERT INTO account_usage_snapshots (agent_kind, snapshot, updated_at) From 6e5aed21a7b7ddcd9b81725dd1a7fc9dbb1b77b4 Mon Sep 17 00:00:00 2001 From: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:55:53 +1200 Subject: [PATCH 2/7] =?UTF-8?q?fix(usage):=20=E6=9D=83=E5=A8=81=E7=9A=84?= =?UTF-8?q?=E5=B7=B2=E8=BE=BE=E9=99=90=E9=A2=9D=E6=A0=87=E8=AE=B0=E5=8D=B3?= =?UTF-8?q?=E4=BD=BF=E6=B2=A1=E6=9C=89=E7=AA=97=E5=8F=A3=E4=B9=9F=E8=A6=81?= =?UTF-8?q?=E8=90=BD=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前一个 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 Signed-off-by: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com> --- ...usageBroadcasterClaudeSubscription.test.ts | 17 ++++++++++ .../usageBroadcasterCodexAccount.test.ts | 33 +++++++++++++++++++ apps/desktop/src/main/usageBroadcaster.ts | 31 +++++++++++++---- 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts b/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts index a8f67872ca..704e4a129e 100644 --- a/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts +++ b/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts @@ -106,6 +106,23 @@ describe('claude subscription snapshot hydration race', () => { expect(mocks.exec).not.toHaveBeenCalled(); }); + it('persists a rejected status even without windows (与 codex 侧 reached 标记同口径)', async () => { + const broadcaster = await import('../usageBroadcaster'); + // rejected 是权威的「请求已被拒」信号(isClaudeSubscriptionAlerting 直接据此告警), + // 缺窗口时也必须落库 —— 否则重启后 chip 不知道当前正被限流。 + mocks.queryOne.mockRejectedValue(new Error('db busy')); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: null, + sevenDay: null, + rateLimitStatus: 'rejected', + source: 'unified-headers', + updatedAt: 5, + }); + + expect(mocks.exec).toHaveBeenCalled(); + }); + it('persists a status-only snapshot merged onto hydrated windows (regression guard)', async () => { const broadcaster = await import('../usageBroadcaster'); // hydration 正常命中 → status-only 增量并入已有窗口, 照常落库且窗口保留。 diff --git a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts index 60c982a807..00e4b7522b 100644 --- a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts +++ b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts @@ -428,6 +428,39 @@ describe('empty snapshot must not clobber the persisted row', () => { expect(mocks.exec).not.toHaveBeenCalled(); }); + // 权威的「已达限额」标记本身就是要落库的状态: 它没有窗口是正常的(如 credits + // 耗尽), 且 isCodexWindowlessFallback 明确把它当权威值 —— merge 会正当地把旧窗口 + // 清成 null。goal-host 的 getAccountLimit 从持久化的 rateLimitReachedType 判 + // limited, 漏存会让重启后暂停的目标直接重新撞进同一个限额。 + const CREDITS_DEPLETED_EVENT = { + limitId: 'codex', + primary: null, + secondary: null, + rateLimitReachedType: 'credits_depleted', + source: 'codex-app-server', + }; + + it('persists an authoritative rate-limit-reached marker even without windows', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockResolvedValue({ snapshot: JSON.stringify(APP_SERVER_SNAPSHOT) }); + + await broadcaster.recordCodexAccountUsageSnapshot(CREDITS_DEPLETED_EVENT); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.rateLimitReachedType).toBe('credits_depleted'); + }); + + it('persists a reached marker arriving on an empty cache too', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockRejectedValue(new Error('db busy')); + + await broadcaster.recordCodexAccountUsageSnapshot(CREDITS_DEPLETED_EVENT); + + expect(mocks.exec).toHaveBeenCalled(); + }); + it('still persists windowless events merged onto hydrated windows (regression guard)', async () => { const broadcaster = await import('../usageBroadcaster'); // hydration 正常命中: windowless 稀疏事件按契约并入已有桶, 窗口保留 → 照常落库。 diff --git a/apps/desktop/src/main/usageBroadcaster.ts b/apps/desktop/src/main/usageBroadcaster.ts index 54108592be..3e17b8fbcb 100644 --- a/apps/desktop/src/main/usageBroadcaster.ts +++ b/apps/desktop/src/main/usageBroadcaster.ts @@ -549,19 +549,32 @@ function hasUsableCodexWindow(snapshot: RateLimitSnapshot | null | undefined): b } /** - * payload 是否值得落库: 任一槽(顶层 / 桶表 / web)带可用窗口。 + * 单槽是否有值得落库的内容: 可用窗口, 或权威的「已达限额」标记。 + * + * reached 标记没有窗口是正常形态(如 credits 耗尽), 且 isCodexWindowlessFallback 明确 + * 把它当权威值 —— merge 会正当地把旧窗口清成 null。它必须落库: goal-host 的 + * getAccountLimit 从持久化的 rateLimitReachedType 判 limited, 漏存会让重启后暂停的 + * 目标直接重新撞进同一个限额。 + */ +function hasPersistableCodexSlot(snapshot: RateLimitSnapshot | null | undefined): boolean { + if (!snapshot) return false; + return hasUsableCodexWindow(snapshot) || hasCodexRateLimitReached(snapshot); +} + +/** + * payload 是否值得落库: 任一槽(顶层 / 桶表 / web)有可用窗口或权威 reached 标记。 * * windowless 稀疏事件本身是合法的(app-server 滚动更新契约), merge 靠内存旧值兜住 * 窗口 —— 但内存为空时(hydration 读库失败 / owner 未初始化被跳过)无值可兜, 全 null * payload 会原样 upsert, 把持久化行里的有效数据永久抹掉(2026-08-11 用户实报)。 - * 空 payload 不落库: 保留旧行, 重启后 hydration 仍能读回有效数据。 + * 这种空壳不落库: 保留旧行, 重启后 hydration 仍能读回有效数据。 */ function hasPersistableCodexAccountUsage(payload: CodexAccountUsagePayload | null): boolean { if (!payload) return false; return ( - hasUsableCodexWindow(payload) - || hasUsableCodexWindow(payload.webSnapshot) - || Object.values(payload.appServerBuckets ?? {}).some(hasUsableCodexWindow) + hasPersistableCodexSlot(payload) + || hasPersistableCodexSlot(payload.webSnapshot) + || Object.values(payload.appServerBuckets ?? {}).some(hasPersistableCodexSlot) ); } @@ -778,7 +791,12 @@ async function ensureClaudeSubscriptionUsageLoaded(): Promise { await claudeSubscriptionUsageLoadPromise; } -/** 与 codex 侧 hasUsableCodexWindow 同口径: 窗口非空且 utilization 是有限数。 */ +/** + * 与 codex 侧同口径: 窗口非空且 utilization 是有限数, 或带权威的限流状态。 + * + * rateLimitStatus='rejected' 是「请求已被拒」的权威信号(isClaudeSubscriptionAlerting + * 直接据此告警), 缺窗口时同样必须落库 —— 对应 codex 侧的 rateLimitReachedType。 + */ function hasPersistableClaudeSubscriptionUsage( snapshot: ClaudeSubscriptionUsageSnapshot, ): boolean { @@ -788,6 +806,7 @@ function hasPersistableClaudeSubscriptionUsage( usable(snapshot.fiveHour) || usable(snapshot.sevenDay) || (snapshot.scoped?.some(usable) ?? false) + || snapshot.rateLimitStatus?.trim().toLowerCase() === 'rejected' ); } From 70b4293cc43588876db68890d28a3bde57fc5dcc Mon Sep 17 00:00:00 2001 From: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:23:39 +1200 Subject: [PATCH 3/7] fix(usage): gate snapshot persistence on hydration, not on payload shape 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 Signed-off-by: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com> --- ...usageBroadcasterClaudeSubscription.test.ts | 30 +++- .../usageBroadcasterCodexAccount.test.ts | 87 ++++++++++- apps/desktop/src/main/usageBroadcaster.ts | 143 ++++++++---------- 3 files changed, 171 insertions(+), 89 deletions(-) diff --git a/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts b/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts index 704e4a129e..407377b379 100644 --- a/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts +++ b/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts @@ -110,7 +110,7 @@ describe('claude subscription snapshot hydration race', () => { const broadcaster = await import('../usageBroadcaster'); // rejected 是权威的「请求已被拒」信号(isClaudeSubscriptionAlerting 直接据此告警), // 缺窗口时也必须落库 —— 否则重启后 chip 不知道当前正被限流。 - mocks.queryOne.mockRejectedValue(new Error('db busy')); + mocks.queryOne.mockResolvedValue(null); await broadcaster.recordClaudeSubscriptionUsageSnapshot({ fiveHour: null, @@ -123,6 +123,34 @@ describe('claude subscription snapshot hydration race', () => { expect(mocks.exec).toHaveBeenCalled(); }); + // 反向转换同样必须落库: 库里是 rejected、后续 allowed 的 status-only 事件没有窗口, + // 按内容判会被拦下, 库里就永远停在 rejected —— 重启后 chip 挂着一个假的限流警告。 + it('persists an allowed transition that clears a persisted rejected status', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockResolvedValue({ + snapshot: JSON.stringify({ + fiveHour: null, + sevenDay: null, + rateLimitStatus: 'rejected', + source: 'unified-headers', + updatedAt: 1, + }), + }); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: null, + sevenDay: null, + rateLimitStatus: 'allowed', + source: 'unified-headers', + updatedAt: 5, + }); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.rateLimitStatus).toBe('allowed'); + }); + it('persists a status-only snapshot merged onto hydrated windows (regression guard)', async () => { const broadcaster = await import('../usageBroadcaster'); // hydration 正常命中 → status-only 增量并入已有窗口, 照常落库且窗口保留。 diff --git a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts index 00e4b7522b..1f8bee9f89 100644 --- a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts +++ b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts @@ -10,10 +10,19 @@ const mocks = vi.hoisted(() => ({ queryOne: vi.fn(), exec: vi.fn(async () => undefined), getCurrentUserId: vi.fn(() => 'user-1'), + /** 广播到 renderer 的 payload —— 并发用例据此断言不会闪出空快照。 */ + broadcasts: [] as unknown[], })); vi.mock('electron', () => ({ - BrowserWindow: { getAllWindows: () => [] }, + BrowserWindow: { + getAllWindows: () => [{ + isDestroyed: () => false, + webContents: { + send: (_channel: string, payload: unknown) => { mocks.broadcasts.push(payload); }, + }, + }], + }, })); vi.mock('../logger', () => ({ createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), @@ -58,6 +67,7 @@ describe('codex account usage source slots', () => { mocks.queryOne.mockReset().mockResolvedValue(null); mocks.exec.mockReset().mockResolvedValue(undefined); mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.broadcasts.length = 0; }); it('keeps app-server windows when a WHAM snapshot arrives (no cross-source overwrite)', async () => { @@ -155,6 +165,7 @@ describe('codex app-server limit buckets', () => { mocks.queryOne.mockReset().mockResolvedValue(null); mocks.exec.mockReset().mockResolvedValue(undefined); mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.broadcasts.length = 0; }); // 2026-07-25 用户实报的真实污染行: app 槽被模型专属促销桶(Spark)占据, @@ -244,6 +255,7 @@ describe('codex bucket edge cases (review follow-up)', () => { mocks.queryOne.mockReset().mockResolvedValue(null); mocks.exec.mockReset().mockResolvedValue(undefined); mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.broadcasts.length = 0; }); const BUCKET_A = { @@ -349,6 +361,7 @@ describe('codex stale bucket pruning', () => { mocks.queryOne.mockReset().mockResolvedValue(null); mocks.exec.mockReset().mockResolvedValue(undefined); mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.broadcasts.length = 0; }); it('prunes buckets whose windows expired long ago, keeping the latest one', async () => { @@ -390,6 +403,7 @@ describe('empty snapshot must not clobber the persisted row', () => { mocks.queryOne.mockReset().mockResolvedValue(null); mocks.exec.mockReset().mockResolvedValue(undefined); mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.broadcasts.length = 0; }); // 2026-08-11 用户实报的真实覆盖事故: 一条全 null 的 windowless app-server 事件 @@ -407,7 +421,7 @@ describe('empty snapshot must not clobber the persisted row', () => { source: 'codex-app-server', }; - it('skips persistence when hydration failed and the event carries no usable window', async () => { + it('skips persistence when hydration failed', async () => { const broadcaster = await import('../usageBroadcaster'); // 冷缓存 hydration 读库失败(db busy 等) → 内存为空, 但持久化行还躺着好数据。 mocks.queryOne.mockRejectedValue(new Error('db busy')); @@ -452,9 +466,10 @@ describe('empty snapshot must not clobber the persisted row', () => { expect(persisted.rateLimitReachedType).toBe('credits_depleted'); }); - it('persists a reached marker arriving on an empty cache too', async () => { + it('persists a reached marker on a cold but readable database', async () => { const broadcaster = await import('../usageBroadcaster'); - mocks.queryOne.mockRejectedValue(new Error('db busy')); + // 库里本来就没有行(首次安装) —— 读成功即底子可信, 照常落库。 + mocks.queryOne.mockResolvedValue(null); await broadcaster.recordCodexAccountUsageSnapshot(CREDITS_DEPLETED_EVENT); @@ -474,6 +489,69 @@ describe('empty snapshot must not clobber the persisted row', () => { expect(persisted.primary?.usedPercent).toBe(82); expect(persisted.secondary?.usedPercent).toBe(55); }); + + // 合法的「限额解除」与事故的空壳形状完全一致 —— 按 payload 内容判会把它一并拦下, + // 库里的 reached 标记就再也去不掉: goal-host 据此判 limited=true 且没有重置时间, + // 目标被无限期挂起。判据必须落在「merge 底子是否可信」上。 + it('persists a legitimate clear that removes a previously reached marker', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockResolvedValue({ + snapshot: JSON.stringify({ ...CREDITS_DEPLETED_EVENT, webSnapshot: null }), + }); + + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.rateLimitReachedType ?? null).toBeNull(); + }); + + // codexWebUsageResponseToSnapshot 明确接受只有 plan_type / credits 的 WHAM 响应, + // tooltip 也展示这两项。web-only 账号的首份快照没有任何窗口, 不能因此不落库 —— + // 否则重启或离线启动就丢了套餐与余额。 + it('persists a web snapshot carrying only plan and credits', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockResolvedValue(null); + + await broadcaster.recordCodexAccountUsageSnapshot({ + primary: null, + secondary: null, + credits: { hasCredits: true, unlimited: false, balance: '12.50' }, + planType: 'prolite', + source: 'openai-web', + accountId: 'acc-1', + }); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.webSnapshot?.planType).toBe('prolite'); + expect(persisted.webSnapshot?.credits?.balance).toBe('12.50'); + }); + + // codex 侧原先把 loaded 置位放在 await 之前, 并发的第二笔 record 会立刻返回并在 + // **空内存**上 merge —— 这正是产出全 null payload 的路径(claude 侧早有 load-promise + // 防住)。可观测的后果是向 renderer 广播一份空快照(chip 闪空), 之后才被 hydration + // 覆盖回去。串行化后两笔都等同一次读完成, 不存在这个中间态。 + it('never broadcasts an empty snapshot while hydration is still in flight', async () => { + const broadcaster = await import('../usageBroadcaster'); + let resolveRead!: (value: { snapshot: string } | null) => void; + mocks.queryOne.mockReturnValue(new Promise((res) => { resolveRead = res; })); + + const first = broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + const second = broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + resolveRead({ snapshot: JSON.stringify(APP_SERVER_SNAPSHOT) }); + await Promise.all([first, second]); + + // 每一次广播都必须带着已 hydrate 的窗口, 不能出现窗口为空的中间态。 + expect(mocks.broadcasts.length).toBeGreaterThan(0); + for (const payload of mocks.broadcasts as Array<{ primary?: { usedPercent?: number } | null }>) { + expect(payload?.primary?.usedPercent).toBe(82); + } + const current = await broadcaster.readCodexAccountUsageSnapshot(); + expect(current?.secondary?.usedPercent).toBe(55); + }); }); describe('sparse rate-limit updates without a limitId', () => { @@ -482,6 +560,7 @@ describe('sparse rate-limit updates without a limitId', () => { mocks.queryOne.mockReset().mockResolvedValue(null); mocks.exec.mockReset().mockResolvedValue(undefined); mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.broadcasts.length = 0; }); const SPARK = { diff --git a/apps/desktop/src/main/usageBroadcaster.ts b/apps/desktop/src/main/usageBroadcaster.ts index 3e17b8fbcb..daa6e4553d 100644 --- a/apps/desktop/src/main/usageBroadcaster.ts +++ b/apps/desktop/src/main/usageBroadcaster.ts @@ -329,6 +329,18 @@ export interface CodexAccountUsagePayload extends RateLimitSnapshot { let codexAccountUsageOwner: string | null = null; let codexAccountUsageLoaded = false; +/** + * 冷缓存 hydration 是否**成功读到过库**(读到空行也算)。 + * + * 落库守卫的判据 —— merge 的底子可信才允许写回。读库失败 / owner 未初始化被跳过时 + * 内存是空的, 此时任何 merge 结果都不代表账号真实状态, 写回会抹掉库里的有效数据。 + * 不能改用「payload 内容看起来是否有用」判断: 合法的空(限额解除、credits 清零)与 + * 事故的空形状完全一致, 按内容判会把前者一并拦下。 + */ +let codexAccountUsageHydrated = false; +// 并发 record 必须等同一次 SQLite 读完成后再按到达顺序 merge(与 claude 侧同款) —— +// 否则第二笔会在 loaded 已被置位、内存却仍为空时 merge 出全 null payload。 +let codexAccountUsageLoadPromise: Promise | null = null; /** app-server 桶表: limitId → 该桶最近快照(同桶 merge, 跨桶隔离)。 */ let codexAppServerBuckets: Record = {}; /** 最近更新的 app-server 桶键 —— 顶层兼容位取它。 */ @@ -370,6 +382,7 @@ function resetCodexAccountUsageCacheIfOwnerChanged(): void { if (owner === codexAccountUsageOwner) return; codexAccountUsageOwner = owner; codexAccountUsageLoaded = false; + codexAccountUsageHydrated = false; codexAppServerBuckets = {}; codexAppServerLatestBucketKey = null; codexWebAccountUsageSnapshot = null; @@ -541,68 +554,41 @@ function isCodexWindowlessFallback(snapshot: RateLimitSnapshot): boolean { return !snapshot.primary && !snapshot.secondary; } -/** 窗口可用 = 非空且 usedPercent 是有限数(RateLimitWindow 的必填字段)。 */ -function hasUsableCodexWindow(snapshot: RateLimitSnapshot | null | undefined): boolean { - return [snapshot?.primary, snapshot?.secondary].some( - (window) => Boolean(window) && Number.isFinite(window?.usedPercent), - ); -} - -/** - * 单槽是否有值得落库的内容: 可用窗口, 或权威的「已达限额」标记。 - * - * reached 标记没有窗口是正常形态(如 credits 耗尽), 且 isCodexWindowlessFallback 明确 - * 把它当权威值 —— merge 会正当地把旧窗口清成 null。它必须落库: goal-host 的 - * getAccountLimit 从持久化的 rateLimitReachedType 判 limited, 漏存会让重启后暂停的 - * 目标直接重新撞进同一个限额。 - */ -function hasPersistableCodexSlot(snapshot: RateLimitSnapshot | null | undefined): boolean { - if (!snapshot) return false; - return hasUsableCodexWindow(snapshot) || hasCodexRateLimitReached(snapshot); -} - -/** - * payload 是否值得落库: 任一槽(顶层 / 桶表 / web)有可用窗口或权威 reached 标记。 - * - * windowless 稀疏事件本身是合法的(app-server 滚动更新契约), merge 靠内存旧值兜住 - * 窗口 —— 但内存为空时(hydration 读库失败 / owner 未初始化被跳过)无值可兜, 全 null - * payload 会原样 upsert, 把持久化行里的有效数据永久抹掉(2026-08-11 用户实报)。 - * 这种空壳不落库: 保留旧行, 重启后 hydration 仍能读回有效数据。 - */ -function hasPersistableCodexAccountUsage(payload: CodexAccountUsagePayload | null): boolean { - if (!payload) return false; - return ( - hasPersistableCodexSlot(payload) - || hasPersistableCodexSlot(payload.webSnapshot) - || Object.values(payload.appServerBuckets ?? {}).some(hasPersistableCodexSlot) - ); -} - async function ensureCodexAccountUsageLoaded(): Promise { resetCodexAccountUsageCacheIfOwnerChanged(); if (codexAccountUsageLoaded) return; - codexAccountUsageLoaded = true; - if (!codexAccountUsageOwner) return; - - try { - const row = await getDbClient().queryOne<{ snapshot?: string | null }>( - 'SELECT snapshot FROM account_usage_snapshots WHERE agent_kind = ?', - ['codex'], - ); - if (!row?.snapshot) return; - const parsed = JSON.parse(row.snapshot); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - const slots = splitPersistedCodexAccountUsage(parsed as Record); - codexAppServerBuckets = slots.appServerBuckets; - codexAppServerLatestBucketKey = slots.latestBucketKey; - codexWebAccountUsageSnapshot = slots.web; - } - } catch (err) { - log.warn( - 'readCodexAccountUsageSnapshot failed:', - err instanceof Error ? err.message : String(err), - ); + if (!codexAccountUsageLoadPromise) { + codexAccountUsageLoadPromise = (async () => { + try { + if (!codexAccountUsageOwner) return; + const row = await getDbClient().queryOne<{ snapshot?: string | null }>( + 'SELECT snapshot FROM account_usage_snapshots WHERE agent_kind = ?', + ['codex'], + ); + // 读到库就算 hydrated(无行 = 确认库里本来就没有), 之后允许落库。置位放在 + // JSON.parse 之前: 损坏行解析失败仍应允许被新快照覆盖, 否则一条坏行会永久 + // 堵死写入。 + codexAccountUsageHydrated = true; + if (!row?.snapshot) return; + const parsed = JSON.parse(row.snapshot); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const slots = splitPersistedCodexAccountUsage(parsed as Record); + codexAppServerBuckets = slots.appServerBuckets; + codexAppServerLatestBucketKey = slots.latestBucketKey; + codexWebAccountUsageSnapshot = slots.web; + } + } catch (err) { + log.warn( + 'readCodexAccountUsageSnapshot failed:', + err instanceof Error ? err.message : String(err), + ); + } finally { + codexAccountUsageLoaded = true; + codexAccountUsageLoadPromise = null; + } + })(); } + await codexAccountUsageLoadPromise; } export async function recordCodexAccountUsageSnapshot(snapshot: unknown): Promise { @@ -644,9 +630,9 @@ export async function recordCodexAccountUsageSnapshot(snapshot: unknown): Promis const payload = buildCodexAccountUsagePayload(); broadcastCodexAccountUsage(payload); - // 无可用窗口的 payload 不 upsert(保留旧行), 见 hasPersistableCodexAccountUsage。 - if (!hasPersistableCodexAccountUsage(payload)) { - log.warn('skip persisting codex account usage snapshot without any usable window'); + // merge 的底子不可信时不写回, 见 codexAccountUsageHydrated。 + if (!codexAccountUsageHydrated) { + log.warn('skip persisting codex account usage snapshot: hydration unavailable'); return; } @@ -670,6 +656,8 @@ export async function recordCodexAccountUsageSnapshot(snapshot: unknown): Promis export async function clearCodexAccountUsageSnapshot(): Promise { resetCodexAccountUsageCacheIfOwnerChanged(); codexAccountUsageLoaded = true; + // clear 后库里的状态是已知的(行被删掉), 之后到达的快照可以正常落库。 + codexAccountUsageHydrated = true; codexAppServerBuckets = {}; codexAppServerLatestBucketKey = null; codexWebAccountUsageSnapshot = null; @@ -736,6 +724,8 @@ export function clearXaiRateLimitSnapshot(): void { let claudeSubscriptionUsageOwnerInitialized = false; let claudeSubscriptionUsageOwner: string | null = null; let claudeSubscriptionUsageLoaded = false; +/** 与 codex 侧 codexAccountUsageHydrated 同义: 落库守卫的判据。 */ +let claudeSubscriptionUsageHydrated = false; let claudeSubscriptionUsageSnapshot: ClaudeSubscriptionUsageSnapshot | null = null; // 冷缓存 hydration 的 in-flight promise —— 并发 record 必须等同一次 SQLite 读完成后 // 再按到达顺序 merge, 否则后到的新快照会先写、再被读回的旧持久化行覆盖。 @@ -752,6 +742,7 @@ function resetClaudeSubscriptionUsageCacheIfOwnerChanged(): void { // 首次初始化: loaded / snapshot 本就是初值, 世代不 bump(见上方注释)。 if (isFirstInit) return; claudeSubscriptionUsageLoaded = false; + claudeSubscriptionUsageHydrated = false; claudeSubscriptionUsageSnapshot = null; claudeSubscriptionUsageGeneration += 1; } @@ -770,6 +761,8 @@ async function ensureClaudeSubscriptionUsageLoaded(): Promise { ); // clear / owner 变化抢先发生 → 本次读结果作废, 不覆盖更新的内存状态。 if (generation !== claudeSubscriptionUsageGeneration) return; + // 读到库就算 hydrated(理由同 codex 侧, 含损坏行仍允许被覆盖)。 + claudeSubscriptionUsageHydrated = true; if (!row?.snapshot) return; const parsed = JSON.parse(row.snapshot); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { @@ -791,25 +784,6 @@ async function ensureClaudeSubscriptionUsageLoaded(): Promise { await claudeSubscriptionUsageLoadPromise; } -/** - * 与 codex 侧同口径: 窗口非空且 utilization 是有限数, 或带权威的限流状态。 - * - * rateLimitStatus='rejected' 是「请求已被拒」的权威信号(isClaudeSubscriptionAlerting - * 直接据此告警), 缺窗口时同样必须落库 —— 对应 codex 侧的 rateLimitReachedType。 - */ -function hasPersistableClaudeSubscriptionUsage( - snapshot: ClaudeSubscriptionUsageSnapshot, -): boolean { - const usable = (window: { utilization?: number } | null | undefined): boolean => - Boolean(window) && Number.isFinite(window?.utilization); - return ( - usable(snapshot.fiveHour) - || usable(snapshot.sevenDay) - || (snapshot.scoped?.some(usable) ?? false) - || snapshot.rateLimitStatus?.trim().toLowerCase() === 'rejected' - ); -} - export async function recordClaudeSubscriptionUsageSnapshot(snapshot: unknown): Promise { if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) return; @@ -827,10 +801,9 @@ export async function recordClaudeSubscriptionUsageSnapshot(snapshot: unknown): claudeSubscriptionUsageSnapshot = next; broadcastClaudeSubscriptionUsage(next); - // 与 codex 侧同一条保护: status-only 增量落在空内存缓存上(hydration 读库失败)时, - // merge 无旧值可保, 全空快照不得 upsert 抹掉持久化行里的有效窗口。 - if (!hasPersistableClaudeSubscriptionUsage(next)) { - log.warn('skip persisting claude subscription usage snapshot without any usable window'); + // 与 codex 侧同一条保护: merge 的底子不可信时不写回。 + if (!claudeSubscriptionUsageHydrated) { + log.warn('skip persisting claude subscription usage snapshot: hydration unavailable'); return; } @@ -862,6 +835,8 @@ export async function recordClaudeSubscriptionUsageSnapshot(snapshot: unknown): export async function clearClaudeSubscriptionUsageSnapshot(): Promise { resetClaudeSubscriptionUsageCacheIfOwnerChanged(); claudeSubscriptionUsageLoaded = true; + // clear 后库里的状态是已知的(行被删掉), 之后到达的快照可以正常落库。 + claudeSubscriptionUsageHydrated = true; claudeSubscriptionUsageSnapshot = null; // 仍在飞的冷缓存 hydration 必须作废 —— 否则它读回的旧持久化行会复活刚清掉的数据。 claudeSubscriptionUsageGeneration += 1; From ec13d43765e2147689f12c74a86f2d7962994844 Mon Sep 17 00:00:00 2001 From: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:38:38 +1200 Subject: [PATCH 4/7] fix(usage): keep retrying hydration after a failed read 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 Signed-off-by: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com> --- ...usageBroadcasterClaudeSubscription.test.ts | 22 ++++++++++++++++ .../usageBroadcasterCodexAccount.test.ts | 24 +++++++++++++++++ apps/desktop/src/main/usageBroadcaster.ts | 26 ++++++++----------- 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts b/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts index 407377b379..fd61945ab2 100644 --- a/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts +++ b/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts @@ -106,6 +106,28 @@ describe('claude subscription snapshot hydration race', () => { expect(mocks.exec).not.toHaveBeenCalled(); }); + // 与 codex 侧同一条: 读库失败不得把缓存标记成已加载, 否则本进程之后再也无法落库。 + it('retries hydration after a transient read failure instead of giving up', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockRejectedValueOnce(new Error('db busy')); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: null, sevenDay: null, rateLimitStatus: 'allowed', + source: 'unified-headers', updatedAt: 1, + }); + expect(mocks.exec).not.toHaveBeenCalled(); + + mocks.queryOne.mockResolvedValue(null); + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: { utilization: 42 }, source: 'unified-headers', updatedAt: 2, + }); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.fiveHour?.utilization).toBe(42); + }); + it('persists a rejected status even without windows (与 codex 侧 reached 标记同口径)', async () => { const broadcaster = await import('../usageBroadcaster'); // rejected 是权威的「请求已被拒」信号(isClaudeSubscriptionAlerting 直接据此告警), diff --git a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts index 1f8bee9f89..96b30ef4ee 100644 --- a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts +++ b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts @@ -432,6 +432,30 @@ describe('empty snapshot must not clobber the persisted row', () => { expect(mocks.exec).not.toHaveBeenCalled(); }); + // 读库失败必须保留重试机会: 若把未成功的 hydration 标记成「已加载」, 之后所有刷新 + // 都会在 ensure 开头短路、被落库守卫永久跳过 —— 一次瞬时 db busy 就让本进程再也 + // 无法持久化任何额度数据。 + it('retries hydration after a transient read failure instead of giving up', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockRejectedValueOnce(new Error('db busy')); + + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + expect(mocks.exec).not.toHaveBeenCalled(); + + // 库恢复后, 下一笔完整快照必须能重新读库并正常落库。 + mocks.queryOne.mockResolvedValue({ snapshot: JSON.stringify(APP_SERVER_SNAPSHOT) }); + await broadcaster.recordCodexAccountUsageSnapshot({ + limitId: 'codex', + primary: { usedPercent: 91, windowMinutes: 300, resetsAt: 1_800_000_000 }, + source: 'codex-app-server', + }); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.primary?.usedPercent).toBe(91); + }); + it('skips persistence when the owner is not initialized yet', async () => { const broadcaster = await import('../usageBroadcaster'); // 启动早期 getCurrentUserId 尚不可用 → hydration 被跳过, 内存为空。 diff --git a/apps/desktop/src/main/usageBroadcaster.ts b/apps/desktop/src/main/usageBroadcaster.ts index daa6e4553d..0a53435c6d 100644 --- a/apps/desktop/src/main/usageBroadcaster.ts +++ b/apps/desktop/src/main/usageBroadcaster.ts @@ -328,7 +328,6 @@ export interface CodexAccountUsagePayload extends RateLimitSnapshot { } let codexAccountUsageOwner: string | null = null; -let codexAccountUsageLoaded = false; /** * 冷缓存 hydration 是否**成功读到过库**(读到空行也算)。 * @@ -336,6 +335,10 @@ let codexAccountUsageLoaded = false; * 内存是空的, 此时任何 merge 结果都不代表账号真实状态, 写回会抹掉库里的有效数据。 * 不能改用「payload 内容看起来是否有用」判断: 合法的空(限额解除、credits 清零)与 * 事故的空形状完全一致, 按内容判会把前者一并拦下。 + * + * 它同时充当「无需再读库」的判据 —— 读失败时保持 false, 下一次 record / read 会重试。 + * 若另设一个「已加载」标志并在失败时也置位, 一次瞬时 db busy 就会让本进程之后 + * 所有落库被永久跳过。 */ let codexAccountUsageHydrated = false; // 并发 record 必须等同一次 SQLite 读完成后再按到达顺序 merge(与 claude 侧同款) —— @@ -381,7 +384,6 @@ function resetCodexAccountUsageCacheIfOwnerChanged(): void { const owner = currentAccountUsageOwner(); if (owner === codexAccountUsageOwner) return; codexAccountUsageOwner = owner; - codexAccountUsageLoaded = false; codexAccountUsageHydrated = false; codexAppServerBuckets = {}; codexAppServerLatestBucketKey = null; @@ -556,7 +558,7 @@ function isCodexWindowlessFallback(snapshot: RateLimitSnapshot): boolean { async function ensureCodexAccountUsageLoaded(): Promise { resetCodexAccountUsageCacheIfOwnerChanged(); - if (codexAccountUsageLoaded) return; + if (codexAccountUsageHydrated) return; if (!codexAccountUsageLoadPromise) { codexAccountUsageLoadPromise = (async () => { try { @@ -583,7 +585,7 @@ async function ensureCodexAccountUsageLoaded(): Promise { err instanceof Error ? err.message : String(err), ); } finally { - codexAccountUsageLoaded = true; + // 只清 in-flight 句柄。hydrated 仅在读成功时置位 —— 失败留给下一次重试。 codexAccountUsageLoadPromise = null; } })(); @@ -655,8 +657,7 @@ export async function recordCodexAccountUsageSnapshot(snapshot: unknown): Promis export async function clearCodexAccountUsageSnapshot(): Promise { resetCodexAccountUsageCacheIfOwnerChanged(); - codexAccountUsageLoaded = true; - // clear 后库里的状态是已知的(行被删掉), 之后到达的快照可以正常落库。 + // clear 后库里的状态是已知的(行被删掉): 既不必再读库, 之后到达的快照也可正常落库。 codexAccountUsageHydrated = true; codexAppServerBuckets = {}; codexAppServerLatestBucketKey = null; @@ -723,8 +724,7 @@ export function clearXaiRateLimitSnapshot(): void { // 误丢(headers 单笔 + 端点 180s 节流时, chip 要空到下一次刷新)。 let claudeSubscriptionUsageOwnerInitialized = false; let claudeSubscriptionUsageOwner: string | null = null; -let claudeSubscriptionUsageLoaded = false; -/** 与 codex 侧 codexAccountUsageHydrated 同义: 落库守卫的判据。 */ +/** 与 codex 侧 codexAccountUsageHydrated 同义: 落库守卫 + 「无需再读库」的判据。 */ let claudeSubscriptionUsageHydrated = false; let claudeSubscriptionUsageSnapshot: ClaudeSubscriptionUsageSnapshot | null = null; // 冷缓存 hydration 的 in-flight promise —— 并发 record 必须等同一次 SQLite 读完成后 @@ -741,7 +741,6 @@ function resetClaudeSubscriptionUsageCacheIfOwnerChanged(): void { claudeSubscriptionUsageOwner = owner; // 首次初始化: loaded / snapshot 本就是初值, 世代不 bump(见上方注释)。 if (isFirstInit) return; - claudeSubscriptionUsageLoaded = false; claudeSubscriptionUsageHydrated = false; claudeSubscriptionUsageSnapshot = null; claudeSubscriptionUsageGeneration += 1; @@ -749,7 +748,7 @@ function resetClaudeSubscriptionUsageCacheIfOwnerChanged(): void { async function ensureClaudeSubscriptionUsageLoaded(): Promise { resetClaudeSubscriptionUsageCacheIfOwnerChanged(); - if (claudeSubscriptionUsageLoaded) return; + if (claudeSubscriptionUsageHydrated) return; if (!claudeSubscriptionUsageLoadPromise) { const generation = claudeSubscriptionUsageGeneration; claudeSubscriptionUsageLoadPromise = (async () => { @@ -774,9 +773,7 @@ async function ensureClaudeSubscriptionUsageLoaded(): Promise { err instanceof Error ? err.message : String(err), ); } finally { - if (generation === claudeSubscriptionUsageGeneration) { - claudeSubscriptionUsageLoaded = true; - } + // 只清 in-flight 句柄, 理由同 codex 侧(读失败必须留重试机会)。 claudeSubscriptionUsageLoadPromise = null; } })(); @@ -834,8 +831,7 @@ export async function recordClaudeSubscriptionUsageSnapshot(snapshot: unknown): export async function clearClaudeSubscriptionUsageSnapshot(): Promise { resetClaudeSubscriptionUsageCacheIfOwnerChanged(); - claudeSubscriptionUsageLoaded = true; - // clear 后库里的状态是已知的(行被删掉), 之后到达的快照可以正常落库。 + // clear 后库里的状态是已知的(行被删掉): 既不必再读库, 之后到达的快照也可正常落库。 claudeSubscriptionUsageHydrated = true; claudeSubscriptionUsageSnapshot = null; // 仍在飞的冷缓存 hydration 必须作废 —— 否则它读回的旧持久化行会复活刚清掉的数据。 From 3c86ac1e0339b59ab820e9a761d7c39e06ed3679 Mon Sep 17 00:00:00 2001 From: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:55:34 +1200 Subject: [PATCH 5/7] fix(usage): let hydration fill gaps instead of overwriting memory 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 Signed-off-by: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com> --- ...usageBroadcasterClaudeSubscription.test.ts | 24 ++++++++++++++++++ .../usageBroadcasterCodexAccount.test.ts | 25 +++++++++++++++++++ apps/desktop/src/main/usageBroadcaster.ts | 16 +++++++++--- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts b/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts index fd61945ab2..38d926522e 100644 --- a/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts +++ b/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts @@ -128,6 +128,30 @@ describe('claude subscription snapshot hydration race', () => { expect(persisted.fiveHour?.utilization).toBe(42); }); + // 与 codex 侧同一条: 重试读到的旧行不得顶掉 hydration 失败期间收到的新快照。 + it('keeps snapshots received while hydration was failing', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockRejectedValueOnce(new Error('db busy')); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: { utilization: 42 }, source: 'unified-headers', updatedAt: 2, + }); + expect(mocks.exec).not.toHaveBeenCalled(); + + mocks.queryOne.mockResolvedValue({ + snapshot: JSON.stringify({ + fiveHour: { utilization: 5 }, + scoped: [{ utilization: 12, modelDisplayName: 'Opus' }], + source: 'oauth-endpoint', + updatedAt: 1, + }), + }); + const current = await broadcaster.readClaudeSubscriptionUsageSnapshot(); + // 内存里的 42% 胜出, 同时补上库里独有的 scoped(端点源才有, headers 源没有)。 + expect(current?.fiveHour?.utilization).toBe(42); + expect(current?.scoped?.[0]?.modelDisplayName).toBe('Opus'); + }); + it('persists a rejected status even without windows (与 codex 侧 reached 标记同口径)', async () => { const broadcaster = await import('../usageBroadcaster'); // rejected 是权威的「请求已被拒」信号(isClaudeSubscriptionAlerting 直接据此告警), diff --git a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts index 96b30ef4ee..17b402c531 100644 --- a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts +++ b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts @@ -456,6 +456,31 @@ describe('empty snapshot must not clobber the persisted row', () => { expect(persisted.primary?.usedPercent).toBe(91); }); + // 重试成功时读到的行比内存旧 —— hydration 失败期间收到的观测因守卫未能落库, 只活在 + // 内存里。直接赋值会让 UI 回退到旧额度, 且那些观测永远等不到落库时机。 + it('keeps snapshots received while hydration was failing', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockRejectedValueOnce(new Error('db busy')); + + await broadcaster.recordCodexAccountUsageSnapshot({ + limitId: 'codex', + primary: { usedPercent: 91, windowMinutes: 300, resetsAt: 1_800_000_000 }, + source: 'codex-app-server', + }); + expect(mocks.exec).not.toHaveBeenCalled(); + + // 重试读到的是更旧的持久化行(82%) —— 不得顶掉内存里的 91%。 + mocks.queryOne.mockResolvedValue({ snapshot: JSON.stringify(APP_SERVER_SNAPSHOT) }); + const payload = await broadcaster.readCodexAccountUsageSnapshot(); + expect(payload?.appServerBuckets?.codex?.primary?.usedPercent).toBe(91); + + // 且这份观测在下一笔事件时随 payload 一并落库, 不会永久停在内存里。 + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.primary?.usedPercent).toBe(91); + }); + it('skips persistence when the owner is not initialized yet', async () => { const broadcaster = await import('../usageBroadcaster'); // 启动早期 getCurrentUserId 尚不可用 → hydration 被跳过, 内存为空。 diff --git a/apps/desktop/src/main/usageBroadcaster.ts b/apps/desktop/src/main/usageBroadcaster.ts index 0a53435c6d..7f5967173f 100644 --- a/apps/desktop/src/main/usageBroadcaster.ts +++ b/apps/desktop/src/main/usageBroadcaster.ts @@ -575,9 +575,12 @@ async function ensureCodexAccountUsageLoaded(): Promise { const parsed = JSON.parse(row.snapshot); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { const slots = splitPersistedCodexAccountUsage(parsed as Record); - codexAppServerBuckets = slots.appServerBuckets; - codexAppServerLatestBucketKey = slots.latestBucketKey; - codexWebAccountUsageSnapshot = slots.web; + // hydration 只补空缺, 不覆盖内存 —— 首次读失败后重试期间, 内存里可能已经 + // 装着那段时间收到的**更新**观测(它们因守卫未能落库)。库里的行比它们旧, + // 直接赋值会让 UI 回退到旧额度, 且那些观测永远等不到落库时机。 + codexAppServerBuckets = { ...slots.appServerBuckets, ...codexAppServerBuckets }; + codexAppServerLatestBucketKey = codexAppServerLatestBucketKey ?? slots.latestBucketKey; + codexWebAccountUsageSnapshot = codexWebAccountUsageSnapshot ?? slots.web; } } catch (err) { log.warn( @@ -765,7 +768,12 @@ async function ensureClaudeSubscriptionUsageLoaded(): Promise { if (!row?.snapshot) return; const parsed = JSON.parse(row.snapshot); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - claudeSubscriptionUsageSnapshot = parsed as ClaudeSubscriptionUsageSnapshot; + // 理由同 codex 侧: 内存里可能是重试期间收到的更新快照, 库里的行是旧的 —— + // 以持久化行为底、内存增量在上做一次常规 merge, 而不是直接赋值。 + const persisted = parsed as ClaudeSubscriptionUsageSnapshot; + claudeSubscriptionUsageSnapshot = claudeSubscriptionUsageSnapshot + ? mergeClaudeSubscriptionUsageSnapshot(persisted, claudeSubscriptionUsageSnapshot) + : persisted; } } catch (err) { log.warn( From 68779376380bb2948aa2e2d0fcb1006a937fbfe7 Mon Sep 17 00:00:00 2001 From: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:10:19 +1200 Subject: [PATCH 6/7] fix(usage): merge pending codex buckets field by field on hydration 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 Signed-off-by: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com> --- .../usageBroadcasterCodexAccount.test.ts | 23 +++++++++++++++++++ apps/desktop/src/main/usageBroadcaster.ts | 22 ++++++++++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts index 17b402c531..6dbdb22969 100644 --- a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts +++ b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts @@ -481,6 +481,29 @@ describe('empty snapshot must not clobber the persisted row', () => { expect(persisted.primary?.usedPercent).toBe(91); }); + // 上一条只覆盖了「失败期间收到完整快照」。稀疏事件留下的是一个非空、却全 null 的 + // 同名桶 —— 若按桶键整体覆盖, 持久化桶里的窗口会被它抹掉并在下一笔事件写回库, + // 正好复现本次要防的损坏。必须逐桶走常规 merge。 + it('merges a sparse bucket received while hydration was failing field by field', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockRejectedValueOnce(new Error('db busy')); + + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + expect(mocks.exec).not.toHaveBeenCalled(); + + // 重试读到同一 limitId 的有效桶(82 / 55) —— 窗口不得被内存里的全 null 桶顶掉。 + mocks.queryOne.mockResolvedValue({ snapshot: JSON.stringify(APP_SERVER_SNAPSHOT) }); + const payload = await broadcaster.readCodexAccountUsageSnapshot(); + expect(payload?.appServerBuckets?.codex?.primary?.usedPercent).toBe(82); + expect(payload?.appServerBuckets?.codex?.secondary?.usedPercent).toBe(55); + + // 而且下一笔事件写回库时窗口仍在, 不会把损坏落盘。 + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.primary?.usedPercent).toBe(82); + }); + it('skips persistence when the owner is not initialized yet', async () => { const broadcaster = await import('../usageBroadcaster'); // 启动早期 getCurrentUserId 尚不可用 → hydration 被跳过, 内存为空。 diff --git a/apps/desktop/src/main/usageBroadcaster.ts b/apps/desktop/src/main/usageBroadcaster.ts index 7f5967173f..f0ca09168a 100644 --- a/apps/desktop/src/main/usageBroadcaster.ts +++ b/apps/desktop/src/main/usageBroadcaster.ts @@ -575,12 +575,24 @@ async function ensureCodexAccountUsageLoaded(): Promise { const parsed = JSON.parse(row.snapshot); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { const slots = splitPersistedCodexAccountUsage(parsed as Record); - // hydration 只补空缺, 不覆盖内存 —— 首次读失败后重试期间, 内存里可能已经 - // 装着那段时间收到的**更新**观测(它们因守卫未能落库)。库里的行比它们旧, - // 直接赋值会让 UI 回退到旧额度, 且那些观测永远等不到落库时机。 - codexAppServerBuckets = { ...slots.appServerBuckets, ...codexAppServerBuckets }; + // hydration 不覆盖内存 —— 首次读失败后重试期间, 内存里可能已经装着那段时间 + // 收到的**更新**观测(它们因守卫未能落库)。库里的行比它们旧, 直接赋值会让 UI + // 回退到旧额度, 且那些观测永远等不到落库时机。 + // + // 但也不能按桶键整体覆盖: 那段时间收到的可能是 windowless 稀疏事件, 留下的是 + // 一个非空、却全 null 的同名桶 —— 整桶覆盖会抹掉持久化桶里的窗口, 正好复现 + // 本次要防的损坏。逐桶走常规 merge(持久化桶作 previous), 稀疏事件即按既有 + // 语义保住旧窗口。 + const persistedBuckets = slots.appServerBuckets; + const mergedBuckets: Record = { ...persistedBuckets }; + for (const [key, pending] of Object.entries(codexAppServerBuckets)) { + mergedBuckets[key] = mergeCodexAccountUsageSnapshot(persistedBuckets[key] ?? null, pending); + } + codexAppServerBuckets = mergedBuckets; codexAppServerLatestBucketKey = codexAppServerLatestBucketKey ?? slots.latestBucketKey; - codexWebAccountUsageSnapshot = codexWebAccountUsageSnapshot ?? slots.web; + codexWebAccountUsageSnapshot = codexWebAccountUsageSnapshot + ? mergeCodexAccountUsageSnapshot(slots.web, codexWebAccountUsageSnapshot) + : slots.web; } } catch (err) { log.warn( From e313ae60497578f8cfa98f34bb324f6bb04acf9b Mon Sep 17 00:00:00 2001 From: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:42:51 +1200 Subject: [PATCH 7/7] fix(usage): clear the load promise after awaiting it, not inside 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 Signed-off-by: AwakeyDonkey <118035356+AwakeyDonkey@users.noreply.github.com> --- ...usageBroadcasterClaudeSubscription.test.ts | 27 +++++++++++++++++++ .../usageBroadcasterCodexAccount.test.ts | 20 ++++++++++++++ apps/desktop/src/main/usageBroadcaster.ts | 25 +++++++++++------ 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts b/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts index 38d926522e..e143b2d832 100644 --- a/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts +++ b/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts @@ -152,6 +152,33 @@ describe('claude subscription snapshot hydration race', () => { expect(current?.scoped?.[0]?.modelDisplayName).toBe('Opus'); }); + // 与 codex 侧同款: owner 缺失时 IIFE 同步走完, 句柄不能在它的 finally 里清 —— + // 否则会被外层赋值写回, 之后永远复用这个已 resolve 的 Promise, 再也不查库。 + it('reads the database once the owner becomes available', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.getCurrentUserId.mockReturnValue(null as unknown as string); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: { utilization: 10 }, source: 'unified-headers', updatedAt: 1, + }); + expect(mocks.queryOne).not.toHaveBeenCalled(); + expect(mocks.exec).not.toHaveBeenCalled(); + + // 登录后必须重新查库。跨 owner 变化的那一笔按既有世代语义会被丢弃(它属于换号前 + // 的上下文), 下一笔恢复正常落库。 + mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.queryOne.mockResolvedValue(null); + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: { utilization: 20 }, source: 'unified-headers', updatedAt: 2, + }); + expect(mocks.queryOne).toHaveBeenCalled(); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: { utilization: 30 }, source: 'unified-headers', updatedAt: 3, + }); + expect(mocks.exec).toHaveBeenCalled(); + }); + it('persists a rejected status even without windows (与 codex 侧 reached 标记同口径)', async () => { const broadcaster = await import('../usageBroadcaster'); // rejected 是权威的「请求已被拒」信号(isClaudeSubscriptionAlerting 直接据此告警), diff --git a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts index 6dbdb22969..757c5bd052 100644 --- a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts +++ b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts @@ -504,6 +504,26 @@ describe('empty snapshot must not clobber the persisted row', () => { expect(persisted.primary?.usedPercent).toBe(82); }); + // owner 缺失时 IIFE 在首个 await 之前同步走完 —— 句柄若在它的 finally 里清, 会被 + // 外层赋值写回, 之后 ensure 永远复用这个已 resolve 的 Promise, 再也不查库, 于是 + // hydrated 永远为 false, 本进程之后所有落库都被守卫跳过。 + it('reads the database once the owner becomes available', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.getCurrentUserId.mockReturnValue(null as unknown as string); + + await broadcaster.recordCodexAccountUsageSnapshot(APP_SERVER_SNAPSHOT); + expect(mocks.queryOne).not.toHaveBeenCalled(); + expect(mocks.exec).not.toHaveBeenCalled(); + + // 用户登录后必须重新查库, 并恢复正常落库。 + mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.queryOne.mockResolvedValue(null); + await broadcaster.recordCodexAccountUsageSnapshot(APP_SERVER_SNAPSHOT); + + expect(mocks.queryOne).toHaveBeenCalled(); + expect(mocks.exec).toHaveBeenCalled(); + }); + it('skips persistence when the owner is not initialized yet', async () => { const broadcaster = await import('../usageBroadcaster'); // 启动早期 getCurrentUserId 尚不可用 → hydration 被跳过, 内存为空。 diff --git a/apps/desktop/src/main/usageBroadcaster.ts b/apps/desktop/src/main/usageBroadcaster.ts index f0ca09168a..3eddde000a 100644 --- a/apps/desktop/src/main/usageBroadcaster.ts +++ b/apps/desktop/src/main/usageBroadcaster.ts @@ -560,6 +560,10 @@ async function ensureCodexAccountUsageLoaded(): Promise { resetCodexAccountUsageCacheIfOwnerChanged(); if (codexAccountUsageHydrated) return; if (!codexAccountUsageLoadPromise) { + // 句柄的清理必须放在 await 之后, 不能放进下面 IIFE 的 finally —— owner 缺失时 + // IIFE 会在首个 await 之前同步走完, 它 finally 里清掉的句柄随即被本行的赋值写回, + // 之后 ensure 永远复用这个已 resolve 的 Promise, 再也不查库(hydrated 也就永远 + // 是 false, 本进程之后所有落库都被守卫跳过)。 codexAccountUsageLoadPromise = (async () => { try { if (!codexAccountUsageOwner) return; @@ -599,13 +603,15 @@ async function ensureCodexAccountUsageLoaded(): Promise { 'readCodexAccountUsageSnapshot failed:', err instanceof Error ? err.message : String(err), ); - } finally { - // 只清 in-flight 句柄。hydrated 仅在读成功时置位 —— 失败留给下一次重试。 - codexAccountUsageLoadPromise = null; } })(); } - await codexAccountUsageLoadPromise; + try { + await codexAccountUsageLoadPromise; + } finally { + // hydrated 仅在读成功时置位 —— 读失败清掉句柄即留下重试机会。 + codexAccountUsageLoadPromise = null; + } } export async function recordCodexAccountUsageSnapshot(snapshot: unknown): Promise { @@ -792,13 +798,16 @@ async function ensureClaudeSubscriptionUsageLoaded(): Promise { 'readClaudeSubscriptionUsageSnapshot failed:', err instanceof Error ? err.message : String(err), ); - } finally { - // 只清 in-flight 句柄, 理由同 codex 侧(读失败必须留重试机会)。 - claudeSubscriptionUsageLoadPromise = null; } })(); } - await claudeSubscriptionUsageLoadPromise; + try { + await claudeSubscriptionUsageLoadPromise; + } finally { + // 清理放在 await 之后, 理由同 codex 侧: owner 缺失时 IIFE 同步走完, 放进它的 + // finally 会被外层赋值写回, 之后永远复用这个已 resolve 的 Promise。 + claudeSubscriptionUsageLoadPromise = null; + } } export async function recordClaudeSubscriptionUsageSnapshot(snapshot: unknown): Promise {