Skip to content

/stats permanently double-counts a session if /stats is opened during the first-ever turn (introduced by #4779) #4994

@BenGuanRan

Description

@BenGuanRan

What happened?

After PR #4779 (feat(stats): add interactive /stats dashboard), the very first time /stats is opened during a fresh first-run flow, the in-progress session ends up persisted twice to ~/.qwen/usage_record.jsonl with the same sessionId. Every subsequent /stats then aggregates the duplicates without dedup, inflating sessions, tokens, durations, tool counts, deltas, heatmap, and project totals — 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):

  1. packages/core/src/services/usageHistoryService.ts:271-281loadUsageHistory() returns the contents of usage_record.jsonl; if missing/empty it falls back to rebuildFromSessionJsonl().
  2. usageHistoryService.ts:261-266rebuildFromSessionJsonl() writes every reconstructed record to usage_record.jsonl (including the in-progress current session, since its chat JSONL is being appended live under projects/*/chats/). The seenSessionIds set 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.
  3. usageHistoryService.ts:104-120 — when the session ends (/clear or process exit), persistSessionUsage() appends the same sessionId again. No dedup against existing file content.
  4. packages/cli/src/ui/utils/statsDataService.ts:247-256loadStatsData() only filters out the live current session (persisted.filter(r => r.sessionId !== currentSession.sessionId)) — two already-persisted copies both survive.
  5. usageHistoryService.ts:313-428aggregateUsage() sums across records and uses filtered.length as sessionCount (line 419). No sessionId dedup.

Deterministic repro (against main @ ac040d0a6, using built packages/core/dist):

mkdir -p /tmp/qwen-stats-repro/.qwen
QWEN_HOME=/tmp/qwen-stats-repro/.qwen node repro.mjs

The script plants a single chat JSONL with one api_response UI telemetry event (1600 total tokens), then runs three steps:

  • Step 1 — calls loadUsageHistory() (simulates first /stats open). Side effect: rebuildFromSessionJsonl() writes the in-progress session to usage_record.jsonl (1 record).
  • Step 2 — calls persistSessionUsage() for the same sessionId (simulates /clear or exit). File now has 2 records, same sessionId.
  • Step 3 — calls loadUsageHistory() + aggregateUsage('all') (simulates the next /stats open).

Actual output:

=== Step 1: open /stats (first time) — triggers loadUsageHistory -> rebuildFromSessionJsonl ===
records returned by loadUsageHistory: 1
lines in usage_record.jsonl: 1
sessionIds in file: repro-session-aaaa-bbbb-cccc

=== Step 2: simulate /clear or session-exit — persistSessionUsage for SAME sessionId ===
lines in usage_record.jsonl: 2
sessionIds in file: repro-session-aaaa-bbbb-cccc, repro-session-aaaa-bbbb-cccc

=== Step 3: re-open /stats — aggregate persisted records ===
records returned by loadUsageHistory: 2
aggregateUsage('all') report:
  sessionCount = 2   (expected: 1, since only ONE logical session existed)
  totalTokens  = 3200   (expected: 1600 from the single ~1.6k session)

🐛 BUG CONFIRMED: session double-counted (sessions=2, tokens=3200)

What did you expect to happen?

/stats should report sessionCount=1 and totalTokens=1600 for a single logical session — even if the session was rebuilt-from-jsonl during the first /stats open and persistSessionUsage() was later called on /clear/exit. Duplicate records by sessionId should not be double-counted.

Client information

Client Information

Reproduced against main @ ac040d0a6 (built with npm 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):

  1. Read side (usageHistoryService.ts:313+ / statsDataService.ts:247+): dedup persisted records by sessionId with last-wins (e.g. fold into a Map<sessionId, record> in aggregateUsage / loadStatsData). This protects existing users whose usage_record.jsonl already contains duplicates from this bug.
  2. Write side (usageHistoryService.ts:261+): in rebuildFromSessionJsonl, skip writing the in-progress session (e.g., compare sessionId against 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() → assert sessionCount === 1.

Related:

中文小结

问题:PR #4779 引入的 /stats 仪表盘存在一处会话重复计数 bug。在全新首次运行流程下,只要在第一个会话期间打开过一次 /stats,该会话就会被永久双计 —— 之后所有 /stats 的 sessions / tokens / 时长 / 工具调用 / 趋势 / heatmap / 项目维度全部 2×,且重复记录永久留在 ~/.qwen/usage_record.jsonl 中。

这正是 @wenshao#4779 review 中提出、被作者以"正常使用不会发生"关闭的 aggregateUsage 去重 Critical,实测在完全正常的首次运行流程下会发生。

机制(直接读 main ac040d0a6):

  1. usageHistoryService.ts:271-281 —— loadUsageHistory()usage_record.jsonl 不存在/为空,调用 rebuildFromSessionJsonl()
  2. usageHistoryService.ts:261-266 —— rebuildFromSessionJsonl() 在读取过程中把所有重建出的记录(含进行中的当前会话)写回文件。第 188/217 行的 seenSessionIds 仅在单次 rebuild 扫描内去重,无法挡住"rebuild 写入的记录"与"后续 live 持久化的同 sessionId 记录"共存。
  3. usageHistoryService.ts:104-120 —— 会话结束(/clear 或退出)时,persistSessionUsage() 再追加同 sessionId,不与文件已有内容去重。
  4. statsDataService.ts:247-256 —— loadStatsData() 只对当前 live 会话过滤一次,两条已落盘的副本都会保留。
  5. usageHistoryService.ts:313-428 —— aggregateUsage() 直接累加,sessionCount = filtered.length(第 419 行),全程无 sessionId 去重。

确定性复现(main ac040d0a6 + 已构建的 packages/core/dist):

mkdir -p /tmp/qwen-stats-repro/.qwen
QWEN_HOME=/tmp/qwen-stats-repro/.qwen node repro.mjs

脚本三步:

  • Step 1:放一个含 api_response 遥测事件(1600 tokens)的 chat jsonl,调用 loadUsageHistory()(模拟首次 /stats),观察 usage_record.jsonl 出现 1 条记录。
  • Step 2:用相同 sessionId 调用 persistSessionUsage()(模拟 /clear/退出),观察文件变成 2 条(同 sessionId)。
  • Step 3:再次 loadUsageHistory() + aggregateUsage('all'),观察 sessionCount = 2totalTokens = 3200(实际应为 1 / 1600)。

预期:单个逻辑会话应汇总为 sessions=1, tokens=1600,按 sessionId 去重,不应因为 rebuild 与 live 持久化各写一次就 2×。

修复方向(建议同时做两侧防御)

  1. 读侧(usageHistoryService.ts:313+ / statsDataService.ts:247+aggregateUsage / loadStatsDatasessionId 做 last-wins 去重(Map<sessionId, record>),保护磁盘上已有脏数据的存量用户。
  2. 写侧(usageHistoryService.ts:261+rebuildFromSessionJsonl 跳过进行中的当前会话(按 sessionId 比较,或跳过源 chat jsonl 最近被修改过的会话),从源头避免新增重复。
  3. 测试:新增一个 e2e 单测覆盖 loadUsageHistory → persistSessionUsage(同 sessionId) → loadUsageHistory + aggregateUsage,断言 sessionCount === 1

关联:PR #4779(引入)、@wenshao#4779 的 review、#4987 / #4993(同 PR 范围外的 IME 光标 revert 恢复,与本 issue 无关)。

Full repro script (repro.mjs)
// 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);
}

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions