// Repro: /stats session double-count after first-ever /stats + /clear (or exit)
// See PR #4779 review by @wenshao and issue #4987-follow-up
//
// Run from repo root with:
// QWEN_HOME=/tmp/qwen-stats-repro.EVVw5S/.qwen node /tmp/qwen-stats-repro.EVVw5S/repro.mjs
import fs from 'node:fs';
import path from 'node:path';
import {
loadUsageHistory,
aggregateUsage,
persistSessionUsage,
} from '/Users/ben/workspace/qwen-code/packages/core/dist/index.js';
const QWEN_HOME = process.env.QWEN_HOME;
if (!QWEN_HOME) throw new Error('Set QWEN_HOME first');
const usagePath = path.join(QWEN_HOME, 'usage_record.jsonl');
const projDir = path.join(QWEN_HOME, 'projects', 'repro-project');
const chatsDir = path.join(projDir, 'chats');
fs.mkdirSync(chatsDir, { recursive: true });
const SESSION_ID = 'repro-session-aaaa-bbbb-cccc';
const START = new Date('2026-06-11T00:00:00Z').toISOString();
const MID = new Date('2026-06-11T00:01:00Z').toISOString();
const END = new Date('2026-06-11T00:02:00Z').toISOString();
const baseCwd = '/repro/project';
const baseRecord = {
sessionId: SESSION_ID,
cwd: baseCwd,
uuid: 'u1',
parentUuid: null,
};
// First record (user msg) — establishes sessionId / cwd / startTime
const recUser = {
...baseRecord,
timestamp: START,
type: 'user',
message: { role: 'user', content: 'hello' },
};
// A UI telemetry event (api_response) — this is what rebuildFromSessionJsonl needs to count the session as having events
const recTelemetry = {
...baseRecord,
uuid: 'u2',
parentUuid: 'u1',
timestamp: MID,
type: 'system',
subtype: 'ui_telemetry',
systemPayload: {
uiEvent: {
'event.name': 'qwen-code.api_response',
'event.timestamp': MID,
response_id: 'r1',
model: 'qwen-max',
duration_ms: 1200,
input_token_count: 1000,
output_token_count: 500,
cached_content_token_count: 200,
thoughts_token_count: 100,
total_token_count: 1600,
prompt_id: 'p1',
},
},
};
const recLast = {
...baseRecord,
uuid: 'u3',
parentUuid: 'u2',
timestamp: END,
type: 'assistant',
message: { role: 'assistant', content: 'world' },
};
const chatJsonl = [recUser, recTelemetry, recLast]
.map((r) => JSON.stringify(r))
.join('\n') + '\n';
fs.writeFileSync(path.join(chatsDir, `${SESSION_ID}.jsonl`), chatJsonl);
console.log(`\n=== Step 0: fresh state ===`);
console.log(`usage_record.jsonl exists? ${fs.existsSync(usagePath)}`);
console.log(`\n=== Step 1: open /stats (first time) — triggers loadUsageHistory -> rebuildFromSessionJsonl ===`);
const first = await loadUsageHistory();
console.log(`records returned by loadUsageHistory: ${first.length}`);
console.log(`usage_record.jsonl exists? ${fs.existsSync(usagePath)}`);
const fileLines1 = fs.readFileSync(usagePath, 'utf8').trim().split('\n').filter(Boolean);
console.log(`lines in usage_record.jsonl: ${fileLines1.length}`);
console.log(
`sessionIds in file: ${fileLines1.map((l) => JSON.parse(l).sessionId).join(', ')}`,
);
console.log(`\n=== Step 2: simulate /clear or session-exit — persistSessionUsage for SAME sessionId ===`);
persistSessionUsage({
sessionId: SESSION_ID,
startTime: new Date(START),
endTime: new Date(END),
project: baseCwd,
metrics: {
models: {
'qwen-max': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 1200 },
tokens: { prompt: 1000, candidates: 500, total: 1600, cached: 200, thoughts: 100 },
bySource: {},
},
},
tools: { totalCalls: 0, totalSuccess: 0, totalFail: 0, totalDurationMs: 0, totalDecisions: {}, byName: {} },
files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
},
});
const fileLines2 = fs.readFileSync(usagePath, 'utf8').trim().split('\n').filter(Boolean);
console.log(`lines in usage_record.jsonl: ${fileLines2.length}`);
console.log(
`sessionIds in file: ${fileLines2.map((l) => JSON.parse(l).sessionId).join(', ')}`,
);
console.log(`\n=== Step 3: re-open /stats — aggregate persisted records ===`);
const second = await loadUsageHistory();
console.log(`records returned by loadUsageHistory: ${second.length}`);
const report = aggregateUsage(second, 'all');
console.log(`\naggregateUsage('all') report:`);
console.log(` sessionCount = ${report.sessionCount} (expected: 1, since only ONE logical session existed)`);
let totalTokens = 0;
for (const m of Object.values(report.models)) totalTokens += m.totalTokens;
console.log(` totalTokens = ${totalTokens} (expected: 1600 from the single ~1.6k session)`);
console.log(`\n=== Verdict ===`);
if (report.sessionCount === 2 && totalTokens === 3200) {
console.log(`🐛 BUG CONFIRMED: session double-counted (sessions=${report.sessionCount}, tokens=${totalTokens})`);
process.exit(1);
} else if (report.sessionCount === 1) {
console.log(`✅ no double count`);
process.exit(0);
} else {
console.log(`⚠️ unexpected result: sessions=${report.sessionCount}, tokens=${totalTokens}`);
process.exit(2);
}
What happened?
After PR #4779 (
feat(stats): add interactive /stats dashboard), the very first time/statsis opened during a fresh first-run flow, the in-progress session ends up persisted twice to~/.qwen/usage_record.jsonlwith the samesessionId. Every subsequent/statsthen aggregates the duplicates without dedup, inflating sessions, tokens, durations, tool counts, deltas, heatmap, and project totals 2× — permanently, since the duplicate stays in the file.This is the same
aggregateUsage-dedup concern @wenshao raised on PR #4779 (the long runtime-verification review) that was closed as "won't happen in normal use." It does — from a completely ordinary first-run sequence.Mechanism (read directly from
main@ac040d0a6):packages/core/src/services/usageHistoryService.ts:271-281—loadUsageHistory()returns the contents ofusage_record.jsonl; if missing/empty it falls back torebuildFromSessionJsonl().usageHistoryService.ts:261-266—rebuildFromSessionJsonl()writes every reconstructed record tousage_record.jsonl(including the in-progress current session, since its chat JSONL is being appended live underprojects/*/chats/). TheseenSessionIdsset on line 188/217 only dedups within a single rebuild scan — it doesn't prevent a rebuild-written record from coexisting with a later live persist of the same session.usageHistoryService.ts:104-120— when the session ends (/clearor process exit),persistSessionUsage()appends the samesessionIdagain. No dedup against existing file content.packages/cli/src/ui/utils/statsDataService.ts:247-256—loadStatsData()only filters out the live current session (persisted.filter(r => r.sessionId !== currentSession.sessionId)) — two already-persisted copies both survive.usageHistoryService.ts:313-428—aggregateUsage()sums across records and usesfiltered.lengthassessionCount(line 419). NosessionIddedup.Deterministic repro (against
main@ac040d0a6, using builtpackages/core/dist):The script plants a single chat JSONL with one
api_responseUI telemetry event (1600 total tokens), then runs three steps:loadUsageHistory()(simulates first/statsopen). Side effect:rebuildFromSessionJsonl()writes the in-progress session tousage_record.jsonl(1 record).persistSessionUsage()for the samesessionId(simulates/clearor exit). File now has 2 records, samesessionId.loadUsageHistory()+aggregateUsage('all')(simulates the next/statsopen).Actual output:
What did you expect to happen?
/statsshould reportsessionCount=1andtotalTokens=1600for a single logical session — even if the session was rebuilt-from-jsonl during the first/statsopen andpersistSessionUsage()was later called on/clear/exit. Duplicate records bysessionIdshould not be double-counted.Client information
Client Information
Reproduced against
main@ac040d0a6(built withnpm ci && npm run build), Node v22.x, macOS (Darwin 23.4.0). Mechanism is filesystem-/code-level and platform-independent.Login information
N/A — bug is in local persistence + aggregation, not in any provider/auth path.
Anything else we need to know?
Suggested fix direction (defense-in-depth — recommend both):
usageHistoryService.ts:313+/statsDataService.ts:247+): dedup persisted records bysessionIdwith last-wins (e.g. fold into aMap<sessionId, record>inaggregateUsage/loadStatsData). This protects existing users whoseusage_record.jsonlalready contains duplicates from this bug.usageHistoryService.ts:261+): inrebuildFromSessionJsonl, skip writing the in-progress session (e.g., comparesessionIdagainst the current session, or skip records whose source chat JSONL was modified within some recent window). Prevents new duplicates from being created.Coverage: add a test that mirrors the repro flow —
loadUsageHistory()→persistSessionUsage()(same sessionId) →loadUsageHistory()+aggregateUsage()→ assertsessionCount === 1.Related:
/statsPR issue — IME cursor revert — unrelated to this one)中文小结
问题:PR #4779 引入的
/stats仪表盘存在一处会话重复计数 bug。在全新首次运行流程下,只要在第一个会话期间打开过一次/stats,该会话就会被永久双计 —— 之后所有/stats的 sessions / tokens / 时长 / 工具调用 / 趋势 / heatmap / 项目维度全部 2×,且重复记录永久留在~/.qwen/usage_record.jsonl中。这正是 @wenshao 在 #4779 review 中提出、被作者以"正常使用不会发生"关闭的
aggregateUsage去重 Critical,实测在完全正常的首次运行流程下会发生。机制(直接读 main
ac040d0a6):usageHistoryService.ts:271-281——loadUsageHistory()若usage_record.jsonl不存在/为空,调用rebuildFromSessionJsonl()。usageHistoryService.ts:261-266——rebuildFromSessionJsonl()在读取过程中把所有重建出的记录(含进行中的当前会话)写回文件。第 188/217 行的seenSessionIds仅在单次 rebuild 扫描内去重,无法挡住"rebuild 写入的记录"与"后续 live 持久化的同 sessionId 记录"共存。usageHistoryService.ts:104-120—— 会话结束(/clear或退出)时,persistSessionUsage()再追加同sessionId,不与文件已有内容去重。statsDataService.ts:247-256——loadStatsData()只对当前 live 会话过滤一次,两条已落盘的副本都会保留。usageHistoryService.ts:313-428——aggregateUsage()直接累加,sessionCount = filtered.length(第 419 行),全程无sessionId去重。确定性复现(main
ac040d0a6+ 已构建的packages/core/dist):脚本三步:
api_response遥测事件(1600 tokens)的 chat jsonl,调用loadUsageHistory()(模拟首次/stats),观察usage_record.jsonl出现 1 条记录。persistSessionUsage()(模拟/clear/退出),观察文件变成 2 条(同 sessionId)。loadUsageHistory()+aggregateUsage('all'),观察sessionCount = 2、totalTokens = 3200(实际应为 1 / 1600)。预期:单个逻辑会话应汇总为
sessions=1, tokens=1600,按 sessionId 去重,不应因为 rebuild 与 live 持久化各写一次就 2×。修复方向(建议同时做两侧防御):
usageHistoryService.ts:313+/statsDataService.ts:247+):aggregateUsage/loadStatsData按sessionId做 last-wins 去重(Map<sessionId, record>),保护磁盘上已有脏数据的存量用户。usageHistoryService.ts:261+):rebuildFromSessionJsonl跳过进行中的当前会话(按 sessionId 比较,或跳过源 chat jsonl 最近被修改过的会话),从源头避免新增重复。loadUsageHistory → persistSessionUsage(同 sessionId) → loadUsageHistory + aggregateUsage,断言sessionCount === 1。关联:PR #4779(引入)、@wenshao 在 #4779 的 review、#4987 / #4993(同 PR 范围外的 IME 光标 revert 恢复,与本 issue 无关)。
Full repro script (repro.mjs)