Skip to content

Commit cefa12a

Browse files
authored
Merge pull request #21 from HOOLC/refactor/desktop-runtime-helpers
[codex] redesign quota list display and watch diagnostics
2 parents 4fce0ea + d30030e commit cefa12a

24 files changed

+3301
-1192
lines changed

docs/internal/codexm-list-display-design.md

Lines changed: 523 additions & 0 deletions
Large diffs are not rendered by default.

docs/internal/design-history/2026-04-10-codexm-watch-eta-design.md

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -147,28 +147,38 @@ eta = remaining_budget / global_rate_in_1w_units_per_hour
147147
也就是说:
148148

149149
- `1W` 先按 plan 的周额度 profile 归一化
150-
- `5H` 先按 plan `5H` 容量 profile 归一化,再换算成 `plus 1W` 单位
150+
- `5H` 先按同一 plan 的原始 `5H:1W` 比例折算到 `1W`,再换算成 `plus 1W` 单位
151151

152152
实现上集中维护一张 plan profile 表:
153153

154-
| plan | `5H` 容量(plus=1) | `1W` 容量(plus=1) |
154+
| plan | 原始 `5H:1W` 比例 | `1W` 容量(plus=1) |
155155
| --- | ---: | ---: |
156-
| `plus` | 1 | 1 |
157-
| `prolite` | 5 | 4.165 |
158-
| `pro` | 10 | 8.33 |
159-
| `team` | 1 | 1 |
160-
| `unknown` | 1 | 1 |
156+
| `plus` | `20/3` | `1` |
157+
| `prolite` | `50/9` | `25/6` |
158+
| `pro` | `50/9` | `25/3` |
159+
| `team` | `20/3` | `1` |
160+
| `unknown` | `20/3` | `1` |
161161

162162
换算规则:
163163

164164
```text
165165
five_hour_equivalent_in_plus_weekly_units =
166-
five_hour_percent * plan.five_hour_capacity_in_plus_units / 8
166+
(five_hour_percent / plan.five_hour_to_one_week_raw_ratio) *
167+
plan.one_week_capacity_in_plus_units
167168
168169
one_week_equivalent_in_plus_weekly_units =
169170
one_week_percent * plan.one_week_capacity_in_plus_units
170171
```
171172

173+
这里不再假设“一周固定包含多少个 5H 窗口”。
174+
175+
实现直接使用每个 plan 的经验原始比例:
176+
177+
- `plus/team``3:20`
178+
- `pro/prolite``9:50`
179+
180+
这样可以把“同一 plan 内的 `5H:1W` 关系”和“不同 plan 间的周额度容量比”分开表达,避免把两层语义混在单一窗口数假设里。
181+
172182
全局速率定义为:
173183

174184
```text
@@ -183,19 +193,34 @@ rate_per_hour = delta_used_percent_in_1w_units / delta_hours
183193
- 至少有一个窗口的 `used_percent``reset_at` 完整
184194
- 时间戳有效
185195

186-
计算相邻样本增量时,分别得到:
196+
速率计算不再直接对每一对相邻样本分别求增量,而是先按“连续 burn 段”分段。
197+
198+
一段连续样本需要同时满足:
199+
200+
- 属于同一账号
201+
- `plan_type` 不变
202+
- `five_hour.used_percent` 不下降
203+
- `one_week.used_percent` 不下降
204+
205+
`reset_at` 只作为辅助信号,不再要求严格字符串相等。
206+
207+
- `reset_at``±1 分钟` 内波动,视为同一窗口的时间戳抖动
208+
- 如果 `used_percent` 回落,说明观察到了 reset,必须开始新的连续段
209+
- 如果账号或 `plan_type` 变化,也必须开始新的连续段
210+
211+
对每个连续段,只看该段首尾两点,分别得到:
187212

188213
- `delta_5h_eq_1w`
189214
- `delta_1w`
190215

191216
其中:
192217

193-
- `5H` 增量只有在前后两点 `five_hour.reset_at` 相同的前提下才有效
194-
- `1W` 增量只有在前后两点 `one_week.reset_at` 相同的前提下才有效
218+
- `5H` 增量来自该连续段首尾 `five_hour.used_percent` 的差值
219+
- `1W` 增量来自该连续段首尾 `one_week.used_percent` 的差值
195220

196-
如果某个窗口的 `reset_at` 变化,说明该窗口已经重置;新旧窗口之间不能直接连算
221+
之所以可以直接取首尾差值,是因为分段阶段已经保证这一段内没有观察到 reset
197222

198-
对于同一对样本,最终增量采用更保守的瓶颈语义:
223+
对于同一连续段,最终增量采用更保守的瓶颈语义:
199224

200225
```text
201226
delta_in_1w_units = max(delta_5h_eq_1w, delta_1w)
@@ -204,8 +229,10 @@ delta_in_1w_units = max(delta_5h_eq_1w, delta_1w)
204229
理由是:
205230

206231
- 两个窗口都在反映同一份真实消耗
232+
- `1W` 更新粒度通常比 `5H` 更粗,逐对相邻样本会被离散跳变放大或低估
207233
- 观测数据存在量化误差、刷新时序差和不同窗口的离散化差异
208-
- 取更大的那个增量更接近“本次真实至少消耗了多少”
234+
- 先按连续段累计,再取更大的那个增量,更接近“这一整段真实至少消耗了多少”
235+
- `reset_at` 抖动不会再把同一段连续 burn 错切成许多碎片
209236

210237
### 6.3 回看窗口
211238

@@ -221,18 +248,19 @@ delta_in_1w_units = max(delta_5h_eq_1w, delta_1w)
221248

222249
### 6.4 计算方式
223250

224-
对回看区间内所有可连接的相邻样本段,计算:
251+
对回看区间内所有有效连续段,计算:
225252

226-
- 总消耗增量(统一到 `1W` 等价单位)
227-
- 总经过时长
253+
- 每段的总消耗增量(统一到 `1W` 等价单位)
254+
- 每段的总经过时长
228255

229256
然后按总量法得到全局速率:
230257

231258
```text
232-
global_rate_in_1w_units_per_hour = sum(delta_in_1w_units) / sum(delta_hours)
259+
global_rate_in_1w_units_per_hour =
260+
sum(segment_delta_in_1w_units) / sum(segment_delta_hours)
233261
```
234262

235-
如果总经过时长为 `0`或没有可用样本段,则该全局速率为空。
263+
如果总经过时长为 `0`或没有可用连续段,则该全局速率为空。
236264

237265
### 6.5 低活跃与空闲
238266

@@ -272,10 +300,10 @@ remaining_budget = min(remaining_5h_eq_1w, remaining_1w_eq)
272300

273301
- 当前活跃账号观测到每小时消耗 `6``plus 1W` 等价单位
274302
- 某个 `pro` 备选账号当前还有 `80%``5H` 剩余
275-
- 它的 `5H` 等价剩余约为 `80 * 10 / 8 = 100`
276-
- 若它的 `1W` 剩余是 `50%`,则其周额度等价剩余约为 `50 * 8.33 = 416.5`
303+
- 它的 `5H` 等价剩余约为 `80 * 10 / (50/9) = 120`
304+
- 若它的 `1W` 剩余是 `50%`,则其周额度等价剩余约为 `50 * (25/3) ≈ 416.67`
277305

278-
则瓶颈是 `100`,最终 ETA 约为 `16.7 小时`
306+
则瓶颈是 `120`,最终 ETA 约为 `20 小时`
279307

280308
这符合产品语义:
281309

@@ -316,7 +344,7 @@ remaining_budget = min(remaining_5h_eq_1w, remaining_1w_eq)
316344

317345
建议新增单独模块,例如:
318346

319-
- `src/watch-history.ts`
347+
- `src/watch/history.ts`
320348

321349
职责:
322350

@@ -344,14 +372,19 @@ CLI 层不直接处理历史文件格式,也不直接实现预测算法。
344372

345373
本次 ETA 设计不替换现有:
346374

347-
- `CURRENT SCORE`
348-
- `1H SCORE`
375+
- 当前实现中的 `SCORE`
376+
- 当前实现中的 `1H SCORE`
349377

350378
二者可以同时存在:
351379

352380
- `score` 继续服务自动切换与排序语义
353381
- `ETA` 负责服务用户的直观时间判断
354382

383+
注:
384+
385+
- 这份文档形成于设计阶段,最终实现中的模块路径为 `src/watch/history.ts`
386+
- list/verbose 表头后续已演进为 `SCORE` / `1H SCORE`
387+
355388
## 9. 失败处理与边界条件
356389

357390
### 9.1 历史不存在

src/account-store/service.ts

Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import type {
4646
import {
4747
extractChatGPTAuth,
4848
fetchQuotaSnapshot,
49+
type QuotaClientMode,
4950
} from "../quota-client.js";
5051
export type {
5152
AccountQuotaSummary,
@@ -59,6 +60,18 @@ export type {
5960
UpdateAccountResult,
6061
} from "./types.js";
6162

63+
const LIST_CACHED_QUOTA_MAX_AGE_MS = 24 * 60 * 60 * 1000;
64+
65+
interface RefreshQuotaOptions {
66+
quotaClientMode?: QuotaClientMode;
67+
allowCachedQuotaFallback?: boolean;
68+
cachedQuotaMaxAgeMs?: number;
69+
}
70+
71+
function isFiniteTimestamp(value: string | undefined): value is string {
72+
return typeof value === "string" && Number.isFinite(Date.parse(value));
73+
}
74+
6275
export class AccountStore {
6376
readonly paths: StorePaths;
6477
readonly fetchImpl?: typeof fetch;
@@ -77,8 +90,10 @@ export class AccountStore {
7790

7891
private async quotaSummaryForAccount(
7992
account: ManagedAccount,
93+
quotaOverride?: QuotaSnapshot,
8094
): Promise<AccountQuotaSummary> {
81-
let planType = account.quota.plan_type ?? null;
95+
const quota = quotaOverride ?? account.quota;
96+
let planType = quota.plan_type ?? null;
8297

8398
try {
8499
const snapshot = await readAuthSnapshotFile(account.authPath);
@@ -94,13 +109,39 @@ export class AccountStore {
94109
user_id: account.user_id ?? null,
95110
identity: account.identity,
96111
plan_type: planType,
97-
credits_balance: account.quota.credits_balance ?? null,
98-
status: account.quota.status,
99-
fetched_at: account.quota.fetched_at ?? null,
100-
error_message: account.quota.error_message ?? null,
101-
unlimited: account.quota.unlimited === true,
102-
five_hour: account.quota.five_hour ?? null,
103-
one_week: account.quota.one_week ?? null,
112+
credits_balance: quota.credits_balance ?? null,
113+
status: quota.status,
114+
fetched_at: quota.fetched_at ?? null,
115+
error_message: quota.error_message ?? null,
116+
unlimited: quota.unlimited === true,
117+
five_hour: quota.five_hour ?? null,
118+
one_week: quota.one_week ?? null,
119+
};
120+
}
121+
122+
private getCachedQuotaFallback(
123+
account: ManagedAccount,
124+
refreshError: Error,
125+
now: Date,
126+
maxAgeMs: number,
127+
): { quota: QuotaSnapshot; warning: string } | null {
128+
const cachedQuota = account.quota;
129+
const cachedFetchedAt = cachedQuota.fetched_at;
130+
if (cachedQuota.status === "error" || !isFiniteTimestamp(cachedFetchedAt)) {
131+
return null;
132+
}
133+
const cachedFetchedAtMs = Date.parse(cachedFetchedAt);
134+
if (now.getTime() - cachedFetchedAtMs > maxAgeMs) {
135+
return null;
136+
}
137+
138+
return {
139+
quota: {
140+
...cachedQuota,
141+
status: "stale",
142+
error_message: refreshError.message,
143+
},
144+
warning: `${account.name} using cached quota from ${cachedFetchedAt} after refresh failed: ${refreshError.message}`,
104145
};
105146
}
106147

@@ -416,7 +457,10 @@ export class AccountStore {
416457
};
417458
}
418459

419-
async refreshQuotaForAccount(name: string): Promise<RefreshQuotaResult> {
460+
async refreshQuotaForAccount(
461+
name: string,
462+
options: RefreshQuotaOptions = {},
463+
): Promise<RefreshQuotaResult> {
420464
ensureAccountName(name);
421465
await this.repository.ensureLayout();
422466

@@ -430,6 +474,7 @@ export class AccountStore {
430474
homeDir: this.paths.homeDir,
431475
fetchImpl: this.fetchImpl,
432476
now,
477+
mode: options.quotaClientMode,
433478
});
434479

435480
if (JSON.stringify(result.authSnapshot) !== JSON.stringify(snapshot)) {
@@ -449,6 +494,24 @@ export class AccountStore {
449494
quota: meta.quota,
450495
};
451496
} catch (error) {
497+
const refreshError =
498+
error instanceof Error ? error : new Error(String(error));
499+
if (options.allowCachedQuotaFallback) {
500+
const fallback = this.getCachedQuotaFallback(
501+
account,
502+
refreshError,
503+
now,
504+
options.cachedQuotaMaxAgeMs ?? LIST_CACHED_QUOTA_MAX_AGE_MS,
505+
);
506+
if (fallback) {
507+
return {
508+
account,
509+
quota: fallback.quota,
510+
warning: fallback.warning,
511+
};
512+
}
513+
}
514+
452515
let planType = meta.quota.plan_type;
453516
try {
454517
const extracted = extractChatGPTAuth(snapshot);
@@ -463,14 +526,17 @@ export class AccountStore {
463526
status: "error",
464527
plan_type: planType,
465528
fetched_at: now.toISOString(),
466-
error_message: (error as Error).message,
529+
error_message: refreshError.message,
467530
};
468531
await this.repository.writeAccountMeta(name, meta);
469-
throw new Error(`Failed to refresh quota for "${name}": ${(error as Error).message}`);
532+
throw new Error(`Failed to refresh quota for "${name}": ${refreshError.message}`);
470533
}
471534
}
472535

473-
async refreshAllQuotas(targetName?: string): Promise<RefreshAllQuotasResult> {
536+
async refreshAllQuotas(
537+
targetName?: string,
538+
options: RefreshQuotaOptions = {},
539+
): Promise<RefreshAllQuotasResult> {
474540
const { accounts } = await this.listAccounts();
475541
const targets = targetName
476542
? accounts.filter((account) => account.name === targetName)
@@ -481,7 +547,7 @@ export class AccountStore {
481547
}
482548

483549
const results = new Array<
484-
| { success: AccountQuotaSummary }
550+
| { success: AccountQuotaSummary; warning?: string }
485551
| { failure: { name: string; error: string } }
486552
>(targets.length);
487553
let nextIndex = 0;
@@ -499,9 +565,10 @@ export class AccountStore {
499565

500566
const account = targets[index];
501567
try {
502-
const refreshed = await this.refreshQuotaForAccount(account.name);
568+
const refreshed = await this.refreshQuotaForAccount(account.name, options);
503569
results[index] = {
504-
success: await this.quotaSummaryForAccount(refreshed.account),
570+
success: await this.quotaSummaryForAccount(refreshed.account, refreshed.quota),
571+
...(refreshed.warning ? { warning: refreshed.warning } : {}),
505572
};
506573
} catch (error) {
507574
results[index] = {
@@ -517,13 +584,17 @@ export class AccountStore {
517584

518585
const successes: AccountQuotaSummary[] = [];
519586
const failures: Array<{ name: string; error: string }> = [];
587+
const warnings: string[] = [];
520588
for (const result of results) {
521589
if (!result) {
522590
continue;
523591
}
524592

525593
if ("success" in result) {
526594
successes.push(result.success);
595+
if (typeof result.warning === "string") {
596+
warnings.push(result.warning);
597+
}
527598
} else {
528599
failures.push(result.failure);
529600
}
@@ -532,6 +603,7 @@ export class AccountStore {
532603
return {
533604
successes,
534605
failures,
606+
warnings,
535607
};
536608
}
537609

src/account-store/storage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { StorePaths } from "./types.js";
77
export const DIRECTORY_MODE = 0o700;
88
export const FILE_MODE = 0o600;
99
export const SCHEMA_VERSION = 1;
10-
export const QUOTA_REFRESH_CONCURRENCY = 3;
10+
export const QUOTA_REFRESH_CONCURRENCY = 8;
1111

1212
const ACCOUNT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
1313

src/account-store/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,11 @@ export interface UpdateAccountResult {
7474
export interface RefreshQuotaResult {
7575
account: ManagedAccount;
7676
quota: QuotaSnapshot;
77+
warning?: string;
7778
}
7879

7980
export interface RefreshAllQuotasResult {
8081
successes: AccountQuotaSummary[];
8182
failures: Array<{ name: string; error: string }>;
83+
warnings: string[];
8284
}

0 commit comments

Comments
 (0)