8000 fix(collector): keep statusline quota flowing past a corrupted line by InfinityPacer · Pull Request #16 · InfinityPacer/meterleaf · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [未发布]

- 状态栏额度缓存里有一行写坏时,采集器只跳过这一行,5 小时和 7 天额度照常更新。此前整份文件会被放弃,额度停在旧值。额度来源出问题或恢复时,采集日志各记一行。
- Fable 周额度到期后显示「等待更新」,不再整行消失。
- 手机首页的账户卡片和下方趋势卡片之间补上间距。

## [0.6.0] - 2026-09-27

- Sub2API 改为可选来源。只使用本地直连 Claude Code 的用户,填写本机采集器的写入密钥即可启动 Meterleaf,不再需要网关数据库;这时页面不显示「数据同步」,采集器推送后报表自动更新。`SUB2API_DATABASE_URL` 与 `METERLEAF_INGEST_KEYS` 至少填写一项。
Expand Down
5 changes: 3 additions & 2 deletions docs/collector.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Claude Code 每一轮都会把最新的 5 小时与 7 天额度传给状态栏
meterleaf-collector statusline-cache ~/.cache/claude-statusline/rate-limits.tsv
```

采集器同样只读这个文件。文件里没有账户和采样时间,采集器把文件修改时间当作采样时间,并按那一刻观察到的登录账户归属;切换账户前后无法判断时跳过这次采样。Meterleaf 对同一账户和窗口使用采样时间最新的一份,两个来源不会相加。停用时执行 `meterleaf-collector statusline-cache off`。
采集器同样只读这个文件。文件里没有账户和采样时间,采集器把文件修改时间当作采样时间,并按那一刻观察到的登录账户归属;切换账户前后无法判断时跳过这次采样。Meterleaf 对同一账户和窗口使用采样时间最新的一份,两个来源不会相加。格式不对或窗口名不认识的行会被跳过,其余行照常使用,跳过时采集日志会记一行说明。停用时执行 `meterleaf-collector statusline-cache off`。

## 日常查看与排障

Expand All @@ -102,7 +102,8 @@ meterleaf-collector statusline-cache ~/.cache/claude-statusline/rate-limits.tsv
- **网络不通或 Meterleaf 暂停**:未送达的数据保存在本机,恢复后自动补发,不会重复计数。
- **更换服务地址**:比如从局域网地址改为公网 HTTPS 地址,执行 `meterleaf-collector server https://meterleaf.example.com`。写入密钥不变,服务端不用改。
- **提示密钥无效**:确认 Meterleaf 的 `METERLEAF_INGEST_KEYS` 包含 `init` 输出的那一行并已重启。换电脑或重新生成密钥时,用 `init --force` 并替换服务端对应的行。
- **额度显示为过时**:Claude Code 并不在每次请求后刷新 `~/.claude.json` 里的额度缓存,观察到的刷新发生在打开设置中的用量面板时。采集器不会主动请求额度,可以启用下面的状态栏额度缓存获得更新的数据。
- **额度显示为过时**:Claude Code 并不在每次请求后刷新 `~/.claude.json` 里的额度缓存,观察到的刷新发生在打开设置中的用量面板时。采集器不会主动请求额度,可以启用上面的状态栏额度缓存获得更新的数据。
- **Fable 显示「等待更新」**:状态栏只拿得到 5 小时与 7 天额度,Fable 周额度只来自 `~/.claude.json` 的缓存。上一周期结束后,在 Claude Code 里打开一次用量面板,下一轮采集就会带上新周期的 Fable 额度。
- **切换过登录账户**:切换前后正在进行的请求可能无法确定属于哪个账户,会保留在「未归属」下,不会被算到当前账户。

停用时执行 `meterleaf-collector uninstall-launchd`。采集器的数据目录位于 `~/Library/Application Support/Meterleaf Collector`,其中保存写入密钥、读取进度和账户归属记录。不要删除它,否则归属记录丢失后,重新读取的历史会改记到「未归属」。
26 changes: 20 additions & 6 deletions src/collector/claude-code/statusline-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,20 @@ export interface StatuslineCache {
percent: number;
resetsAt: string;
}[];
/** 被忽略的非空行数:格式不符或窗口名不认识。 */
ignoredLines: number;
}

const windows = {
five_hour: { window: "five-hour", minutes: 300 },
seven_day: { window: "seven-day", minutes: 10080 },
} as const;

/** 只读打开一次;任一行格式不符就放弃本轮,避免读到写了一半的文件。 */
/**
* 只读打开一次,逐行校验。格式不符或窗口名不认识的行单独忽略并计数,其余行照常使用:
* 状态栏脚本按窗口名替换各自的行,一行写坏后会一直留在文件里,整份放弃会让额度永久停更。
* 没有任何有效行时返回 null。
*/
export function readStatuslineCache(path: string): StatuslineCache | null {
let raw: string;
let mtimeMs: number;
Expand All @@ -44,15 +50,22 @@ export function readStatuslineCache(path: string): StatuslineCache | null {
}
// 脚本可能追加同一窗口的新值,以最后一行为准。
const entries = new Map<string, StatuslineCache["entries"][number]>();
let ignoredLines = 0;
for (const line of raw.split("\n")) {
if (line.trim() === "") continue;
const [name, used, reset, extra] = line.split("\t");
if (extra !== undefined || !name || !Object.hasOwn(windows, name))
return null;
if (!/^\d+(\.\d+)?$/.test(used ?? "") || !/^\d{9,11}$/.test(reset ?? ""))
return null;
const percent = Number(used);
if (percent > 1000) return null;
if (
extra !== undefined ||
!name ||
!Object.hasOwn(windows, name) ||
!/^\d+(\.\d+)?$/.test(used ?? "") ||
!/^\d{9,11}$/.test(reset ?? "") ||
percent > 1000
) {
ignoredLines += 1;
continue;
}
const window = windows[name as keyof typeof windows].window;
entries.set(window, {
window,
Expand All @@ -64,6 +77,7 @@ export function readStatuslineCache(path: string): StatuslineCache | null {
return {
sampledAt: new Date(mtimeMs).toISOString(),
entries: [...entries.values()],
ignoredLines,
};
}

Expand Down
4 changes: 4 additions & 0 deletions src/collector/collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export interface CollectReport {
quotaSkipped: string | null;
statuslineQuotaRecorded: boolean;
statuslineQuotaSkipped: string | null;
/** 状态栏额度缓存中被忽略的行数;其余有效行仍会上报。 */
statuslineIgnoredLines: number;
}

const chunkBytes = 4 * 1024 * 1024;
Expand Down Expand Up @@ -202,6 +204,7 @@ export function emptyReport(): CollectReport {
quotaSkipped: null,
statuslineQuotaRecorded: false,
statuslineQuotaSkipped: null,
statuslineIgnoredLines: 0,
};
}

Expand Down Expand Up @@ -249,6 +252,7 @@ export function collect(
if (!cache) {
report.statuslineQuotaSkipped = "状态栏额度缓存不存在或格式无效";
} else {
report.statuslineIgnoredLines = cache.ignoredLines;
// 文件不带账户,按采样时刻所在的登录区间归属;切换前后无法判断时不上报。
const account = attribute(
cache.sampledAt,
Expand Down
26 changes: 22 additions & 4 deletions src/collector/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,19 @@ function printTotals(totals: UsageTotals[]) {
}
}

/** 两个额度来源的异常合成一行;没有异常时为空字符串。 */
function quotaIssueText(report: CollectReport) {
return [
report.quotaSkipped && `额度: ${report.quotaSkipped}`,
report.statuslineQuotaSkipped &&
`状态栏额度: ${report.statuslineQuotaSkipped}`,
report.statuslineIgnoredLines > 0 &&
`状态栏额度: 忽略 ${report.statuslineIgnoredLines} 行无法识别的内容`,
]
.filter(Boolean)
.join(";");
}

function printReport(report: CollectReport) {
console.log(
`文件 ${format(report.files)}(读取 ${format(report.filesRead)},重读 ${format(report.rewound)}),` +
Expand Down Expand Up @@ -292,9 +305,8 @@ function runScan(paths: CollectorPaths, json: boolean) {
`额度 ${quota.window}: ${quota.percent ?? "未知"}%(采样于 ${quota.sampledAt})`,
);
}
if (report.quotaSkipped) console.log(`额度: ${report.quotaSkipped}`);
if (report.statuslineQuotaSkipped)
console.log(`状态栏额度: ${report.statuslineQuotaSkipped}`);
const quotaIssue = quotaIssueText(report);
if (quotaIssue) console.log(quotaIssue);
console.log("");
printTotals(totals);
console.log("");
Expand All @@ -320,8 +332,14 @@ async function runSync(paths: CollectorPaths): Promise<number> {
...paths,
statuslineCache: config.statuslineCache,
});
const result = await pushOutbox(state, config);
const now = new Date().toISOString();
// 额度来源的问题与推送无关,每轮都会重复,只在情况变化时记一行,恢复时也记一行。
const quotaIssue = quotaIssueText(report);
if (quotaIssue !== (state.getMeta("last_quota_issue") ?? "")) {
state.setMeta("last_quota_issue", quotaIssue);
console.log(`${now} ${quotaIssue || "额度来源已恢复正常"}`);
}
const result = await pushOutbox(state, config);
if (result.error) {
state.setMeta("last_failure_at", now);
state.setMeta("last_failure", result.error.message);
Expand Down
5 changes: 4 additions & 1 deletion src/web/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2223,7 +2223,10 @@ export function App() {
),
selectedAccount,
quotaAsOf,
).some(({ key }) => key === "sevenDayFable") &&
).some(
// 等待新快照时上一周期的重置时间和费用已不适用。
({ key, waiting }) => key === "sevenDayFable" && !waiting,
) &&
selectedAccount.sevenDayFable && (
<>
<dt>Fable 重置</dt>
Expand Down
3 changes: 2 additions & 1 deletion src/web/components/mobile-home.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
width: 100%;
max-width: 700px;
min-width: 0;
margin: 0 auto;
/* 下方紧接趋势卡片,与其后各卡片同样留 16px。 */
margin: 0 auto 16px;
padding: 0;
color: var(--ink);
background: transparent;
Expand Down
26 changes: 19 additions & 7 deletions src/web/lib/quota-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export function visibleQuotaWindows(
return windows;
}

const WEEK_MS = 7 * 24 * 60 * 60 * 1000;

/**
* Fable 周额度只计 Fable 请求,排在共享窗口之后。账户整体周额度用尽时所有模型都不可用,
* 紧凑视图只保留用尽的周额度;上游未上报 Fable 额度时不显示,不按套餐推断有无。
Expand All @@ -79,18 +81,28 @@ export function withFableQuotaWindow(
asOf: string,
): VisibleQuotaWindow[] {
const window = account.sevenDayFable ?? null;
if (
!window ||
quotaState(window, asOf) !== "active" ||
quotaPercent(window, asOf) === null
)
return windows;
if (!window) return windows;
const exhaustedWeekOnly =
windows.length === 1 &&
windows[0]!.key === "sevenDay" &&
quotaPercent(windows[0]!.window, asOf) === 100;
if (exhaustedWeekOnly) return windows;
return [...windows, { key: "sevenDayFable", label: "Fable", window }];
if (
quotaState(window, asOf) === "active" &&
quotaPercent(window, asOf) !== null
)
return [...windows, { key: "sevenDayFable", label: "Fable", window }];
// Fable 额度只随 Claude Code 的用量缓存刷新,可能晚于共享周额度很久才更新。共享周额度有效时
// 保留占位,但上一周期结束超过一周仍无新快照,就不再推定上游还在上报这个窗口。
const endedAt = Date.parse(window.resetsAt ?? "");
const recentlyEnded =
Number.isFinite(endedAt) && Date.parse(asOf) - endedAt < WEEK_MS;
if (!recentlyEnded || !windows.some(({ key }) => key === "sevenDay"))
return windows;
return [
...windows,
{ key: "sevenDayFable", label: "Fable", window, waiting: true },
];
}

/** 等待新采样的窗口只说明上一周期何时结束,不给出百分比或金额。 */
Expand Down
22 changes: 22 additions & 0 deletions tests/collector-statusline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,25 @@ test("malformed or partial statusline files are ignored instead of guessed", ()
{ window: "five-hour", percent: 12, resetsAt: "2026-09-24T19:20:00.000Z" },
]);
});

test("a corrupted line does not block the valid windows beside it", () => {
// 状态栏脚本并发写入曾留下 "fseven_day" 这样的坏行,脚本按窗口名替换时永远删不掉它。
const sources = workspace();
const state = new CollectorState(":memory:");
login(sources.claudeJson, FIXTURE_ACCOUNT_UUID);
collect(state, sources, new Date("2026-09-24T17:00:00Z"));
writeCache(
sources.statuslineCache,
`fseven_day\t0\t${resets7d}\nfive_hour\t47\t${resets5h}\nfive_hour\tabc\t${resets5h}\nseven_day\t9\t${resets7d}\n`,
"2026-09-24T17:00:30Z",
);
const report = collect(state, sources, new Date("2026-09-24T17:01:00Z"));
expect(report.statuslineQuotaRecorded).toBe(true);
expect(report.statuslineIgnoredLines).toBe(2);
expect(
quotas(state).map(({ window, percent }) => ({ window, percent })),
).toEqual([
{ window: "five-hour", percent: 47 },
{ window: "seven-day", percent: 9 },
]);
});
26 changes: 26 additions & 0 deletions tests/quota-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
visibleQuotaWindows,
quotaWaitingReset,
accountQuotaExhausted,
withFableQuotaWindow,
} from "../src/web/lib/quota-display";

type QuotaEstimate = NonNullable<AccountWindow["estimate"]>;
Expand Down Expand Up @@ -159,6 +160,31 @@ test("a reported five-hour window keeps its place while waiting for a new sample
);
});

test("an expired Fable window waits for a new sample instead of disappearing", () => {
const week = { ...window, percent: 10 };
const shown = (sevenDayFable: AccountWindow, sevenDay = week) =>
withFableQuotaWindow(
visibleQuotaWindows({ fiveHour: null, sevenDay }, now),
{ sevenDayFable },
now,
).map((item) => [item.key, item.waiting ?? false]);
// Fable 快照在 4 小时前到期,Claude Code 尚未刷新用量缓存。
const expired = { ...window, percent: 0, resetsAt: "2026-09-08T08:00:00Z" };
expect(shown(expired)).toEqual([
["sevenDay", false],
["sevenDayFable", true],
]);
expect(shown({ ...window, percent: 3 })).toEqual([
["sevenDay", false],
["sevenDayFable", false],
]);
// 上一周期结束超过一周仍无新快照,或共享周额度本身无效时,不再占位。
expect(shown({ ...expired, resetsAt: "2026-08-31T08:00:00Z" })).toEqual([
["sevenDay", false],
]);
expect(shown(expired, { ...week, resetsAt: now })).toEqual([]);
});

test("weekly reset restores five-hour visibility instead of keeping an expired exhausted state", () => {
const account = {
fiveHour: { ...window, percent: 75 },
Expand Down
Loading
0