fix(web-shell): count daemon sessions in Daemon Status usage dashboard (#6493)

* fix(web-shell): count daemon sessions in Daemon Status usage dashboard

The Web Shell usage dashboard read usage_record.jsonl exclusively, but only
the TUI /clear path ever writes that file — so daemon / Web Shell sessions and
any un-cleared session (whose usage lives only in the per-session transcripts)
were never counted. Real-world "today" totals could undercount ~20x.

Add core loadUsageHistoryWithLive(): the durable persisted history unioned with
a bounded replay of recent transcripts, deduped by sessionId (persisted wins,
as the authoritative final snapshot). rebuildFromSessionJsonl gains an mtime
window and a skip-set (read from each transcript's first line) so the merge is
incremental and cheap. The daemon /usage/dashboard route and loadUsageDashboard
now use it.

The trailing window (35d) keeps the summary + daily charts exact while bounding
load latency (~1.7s vs ~13s for a full-year replay on a heavy history); older
heatmap cells fall back to persisted data. With no persisted base (fresh
machine / pure Web Shell user) it replays the full history, so the heatmap is
never silently truncated. Read-only: serving the dashboard never writes ~/.qwen.

* fix(web-shell): address review — skip transcripts by filename, add coverage

Skip already-persisted transcripts by their filename (`{sessionId}.jsonl`,
guaranteed by chatRecordingService) instead of opening each file to read the
sessionId from its first line — zero I/O per skipped session on a cache miss.

Add tests: the loadUsageDashboard wiring counts a transcript-only (daemon)
session, the rebuilt-empty (all-persisted) common case, and a corrupt
usage_record.jsonl falling back to a full transcript replay.
This commit is contained in:
Shaojin Wen 2026-07-08 11:06:05 +08:00 committed by GitHub
parent 045bbee6ce
commit 29cefd7fb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 385 additions and 22 deletions

View file

@ -8,16 +8,17 @@
* Read-only usage-dashboard surface behind the Web Shell Daemon Status
* "统计 / Usage" tab. Serves the selected range's (`today`/`week`/`month`)
* flattened token totals plus a trailing per-day heatmap, computed by core's
* `buildUsageDashboard` from the durable local usage history (global `~/.qwen`,
* cross-project) the same source the TUI `/stats` command reads. The history
* load runs read-only (`persistRebuild: false`), so serving a GET never writes
* to `~/.qwen`, even when the persisted file is missing and the loader falls
* back to transcript replay.
* `buildUsageDashboard` from the local usage history (global `~/.qwen`,
* cross-project). Uses `loadUsageHistoryWithLive`, which unions the durable
* `usage_record.jsonl` (written only by the TUI `/clear` path) with a replay of
* recent transcripts so daemon / Web Shell sessions and any in-progress
* session are counted here, unlike the TUI `/stats` view. The load is
* read-only (never writes `~/.qwen`).
*
* Open GET (no `mutate` gate), consistent with `GET /daemon/status` and
* `GET /scheduled-tasks`: it exposes only aggregate local usage counts.
*
* The heavy step `loadUsageHistory` can replay every project's transcripts
* The heavy step `loadUsageHistoryWithLive` can replay recent transcripts
* is cached once (range-independent), so toggling Today/7D/30D re-aggregates
* cheaply from a single disk read instead of re-loading per range, and
* concurrent requests coalesce onto the in-flight load.
@ -26,7 +27,7 @@
import type { Application } from 'express';
import {
buildUsageDashboard,
loadUsageHistory,
loadUsageHistoryWithLive,
type UsageSummaryRecord,
} from '@qwen-code/qwen-code-core';
import { writeStderrLine } from '../../utils/stdioHelpers.js';
@ -67,9 +68,7 @@ export function registerUsageStatsRoutes(
app: Application,
deps: RegisterUsageStatsRoutesDeps = {},
): void {
const loadHistory =
deps.loadHistory ??
(() => loadUsageHistory(undefined, { persistRebuild: false }));
const loadHistory = deps.loadHistory ?? (() => loadUsageHistoryWithLive());
const ttlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
// Closure-scoped, range-independent history cache (one daemon per process).

View file

@ -98,6 +98,89 @@ describe('loadUsageDashboard', () => {
);
}
/**
* Plant a transcript-only (never-persisted, e.g. daemon / Web Shell) session
* timestamped now, so it lands in the `today` window when replayed.
*/
function plantTranscript(sessionId: string, totalTokens: number): void {
const cwd = '/daemon/project';
const ts = new Date().toISOString();
const chatsDir = path.join(
process.env['QWEN_HOME']!,
'projects',
'daemon-project',
'chats',
);
fs.mkdirSync(chatsDir, { recursive: true });
const records = [
{
sessionId,
cwd,
uuid: 'u1',
parentUuid: null,
timestamp: ts,
type: 'user',
message: { role: 'user', content: 'hi' },
},
{
sessionId,
cwd,
uuid: 'u2',
parentUuid: 'u1',
timestamp: ts,
type: 'system',
subtype: 'ui_telemetry',
systemPayload: {
uiEvent: {
'event.name': 'qwen-code.api_response',
'event.timestamp': ts,
response_id: 'r1',
model: 'qwen-max',
duration_ms: 1200,
input_token_count: totalTokens * 0.6,
output_token_count: totalTokens * 0.4,
cached_content_token_count: 0,
thoughts_token_count: 0,
total_token_count: totalTokens,
prompt_id: 'p1',
},
},
},
{
sessionId,
cwd,
uuid: 'u3',
parentUuid: 'u2',
timestamp: ts,
type: 'assistant',
message: { role: 'assistant', content: 'ok' },
},
];
fs.writeFileSync(
path.join(chatsDir, `${sessionId}.jsonl`),
records.map((r) => JSON.stringify(r)).join('\n') + '\n',
);
}
it('today totals include a daemon / Web Shell transcript session, not just the persisted file', async () => {
// Guards the wiring: loadUsageDashboard must read the live-merging loader,
// so a session that only lives in a transcript (never persisted by /clear)
// still counts. A revert to the persisted-only loader would drop it.
seed([
rec({
sessionId: 'persisted',
timestamp: Date.now(),
model: { totalTokens: 1000, inputTokens: 1000 },
}),
]);
plantTranscript('daemon-live', 1600);
const dash = await loadUsageDashboard({ range: 'today' });
expect(dash.summary.sessions).toBe(2);
expect(dash.summary.totalTokens).toBe(2600);
});
it('flattens today totals across all sessions active today', async () => {
const now = Date.now();
seed([

View file

@ -7,7 +7,7 @@
import {
aggregateUsage,
getTimeRangeBounds,
loadUsageHistory,
loadUsageHistoryWithLive,
type TimeRange,
type UsageSummaryRecord,
} from './usageHistoryService.js';
@ -275,14 +275,15 @@ export function buildUsageDashboard(
/**
* Read-only snapshot of local token usage for the daemon usage-dashboard API.
* Loads the global cross-project history (`~/.qwen`) via {@link loadUsageHistory}
* (persisted `usage_record.jsonl`, falling back to transcript replay) and builds
* the dashboard consistent with the TUI `/stats` view. The load can be I/O
* heavy on large histories, so callers should cache (the daemon route caches the
* loaded records and re-runs {@link buildUsageDashboard} per range).
* Loads the global cross-project history (`~/.qwen`) via
* {@link loadUsageHistoryWithLive} the persisted `usage_record.jsonl` unioned
* with a replay of recent transcripts so the totals include daemon / Web Shell
* and in-progress sessions that the persisted file never captures. The load can
* be I/O heavy on large histories, so callers should cache (the daemon route
* caches the loaded records and re-runs {@link buildUsageDashboard} per range).
*/
export async function loadUsageDashboard(
options: LoadUsageDashboardOptions = {},
): Promise<UsageDashboard> {
return buildUsageDashboard(await loadUsageHistory(), options);
return buildUsageDashboard(await loadUsageHistoryWithLive(), options);
}

View file

@ -12,6 +12,7 @@ import {
metricsToUsageRecord,
aggregateUsage,
loadUsageHistory,
loadUsageHistoryWithLive,
persistSessionUsage,
} from './usageHistoryService.js';
import { ToolCallDecision } from '../telemetry/tool-call-decision.js';
@ -626,6 +627,154 @@ describe('loadUsageHistory + persistSessionUsage (issue #4994 regression)', () =
for (const m of Object.values(report.models)) totalTokens += m.totalTokens;
expect(totalTokens).toBe(1600);
});
// loadUsageHistoryWithLive: the daemon usage-dashboard loader. Unlike
// loadUsageHistory (persisted file verbatim when non-empty), it unions the
// persisted history with a replay of recent transcripts so daemon / Web Shell
// and in-progress sessions — which only the TUI /clear path ever persists —
// are counted. See issue: Web Shell "today" undercounted ~20x.
function planted(sessionId: string): string {
return path.join(
process.env['QWEN_HOME']!,
'projects',
'repro-project',
'chats',
`${sessionId}.jsonl`,
);
}
function seedPersisted(records: UsageSummaryRecord[]) {
fs.writeFileSync(
path.join(process.env['QWEN_HOME']!, 'usage_record.jsonl'),
records.map((r) => JSON.stringify(r)).join('\n') + '\n',
);
}
function persistedRec(sessionId: string, totalTokens: number) {
return {
version: 1 as const,
sessionId,
timestamp: Date.now(),
startTime: Date.now() - 60000,
project: '/p',
durationMs: 60000,
totalLatencyMs: 1200,
models: {
'qwen-max': {
requests: 1,
inputTokens: totalTokens,
outputTokens: 0,
cachedTokens: 0,
thoughtsTokens: 0,
totalTokens,
},
},
tools: { totalCalls: 0, totalSuccess: 0, totalFail: 0, byName: {} },
files: { linesAdded: 0, linesRemoved: 0 },
};
}
it('withLive: unions a never-persisted (daemon) session with the persisted history', async () => {
// Persisted: a finalized TUI session. Transcript-only: a daemon / Web Shell
// session that /clear never wrote to usage_record.jsonl.
seedPersisted([persistedRec('sess-persisted', 1000)]);
plantChatJsonl('sess-daemon', 1600);
const merged = await loadUsageHistoryWithLive();
const ids = merged.map((r) => r.sessionId).sort();
expect(ids).toEqual(['sess-daemon', 'sess-persisted']);
const report = aggregateUsage(merged, 'all');
let totalTokens = 0;
for (const m of Object.values(report.models)) totalTokens += m.totalTokens;
// 1000 persisted + 1600 replayed from the daemon transcript.
expect(totalTokens).toBe(2600);
// Read-only: the persisted file must still hold only the original record.
const lines = fs
.readFileSync(
path.join(process.env['QWEN_HOME']!, 'usage_record.jsonl'),
'utf8',
)
.trim()
.split('\n');
expect(lines).toHaveLength(1);
});
it('withLive: a persisted session with a live transcript is not double-counted (persisted wins)', async () => {
// Same sessionId in both the persisted file (authoritative 1000) and the
// transcript (1600). Must appear exactly once, keeping the persisted value.
seedPersisted([persistedRec('sess-both', 1000)]);
plantChatJsonl('sess-both', 1600);
const merged = await loadUsageHistoryWithLive();
expect(merged).toHaveLength(1);
expect(merged[0]!.sessionId).toBe('sess-both');
expect(merged[0]!.models['qwen-max']!.totalTokens).toBe(1000);
});
it('withLive: with a persisted base, the trailing window excludes stale transcripts (sinceMs:0 includes them)', async () => {
// A persisted base means the window engages (old days come from the file).
seedPersisted([persistedRec('sess-persisted', 500)]);
plantChatJsonl('sess-old', 1600);
// Age the never-persisted transcript well past the default trailing window.
const stale = Date.now() - 100 * 24 * 60 * 60 * 1000;
fs.utimesSync(planted('sess-old'), stale / 1000, stale / 1000);
// Default window: the stale, never-persisted transcript is not replayed.
const windowed = await loadUsageHistoryWithLive();
expect(windowed.map((r) => r.sessionId)).toEqual(['sess-persisted']);
// An unbounded window picks it back up alongside the persisted record.
const all = await loadUsageHistoryWithLive({ sinceMs: 0 });
expect(all.map((r) => r.sessionId).sort()).toEqual([
'sess-old',
'sess-persisted',
]);
});
it('withLive: with no persisted base, replays full history (no silent trailing-window truncation)', async () => {
// No usage_record.jsonl: nothing else covers old history, so an old
// transcript must still be replayed rather than truncated by the window.
plantChatJsonl('sess-old', 1600);
const stale = Date.now() - 100 * 24 * 60 * 60 * 1000;
fs.utimesSync(planted('sess-old'), stale / 1000, stale / 1000);
const merged = await loadUsageHistoryWithLive();
expect(merged.map((r) => r.sessionId)).toEqual(['sess-old']);
});
it('withLive: read-only rebuild — never writes usage_record.jsonl', async () => {
plantChatJsonl('sess-daemon-only', 1600);
const usagePath = path.join(
process.env['QWEN_HOME']!,
'usage_record.jsonl',
);
const merged = await loadUsageHistoryWithLive();
expect(merged.map((r) => r.sessionId)).toEqual(['sess-daemon-only']);
expect(fs.existsSync(usagePath)).toBe(false);
});
it('withLive: all sessions persisted (empty rebuild) returns the persisted records as-is', async () => {
// Common case: no live transcripts at all, only the persisted file.
seedPersisted([persistedRec('sess-only', 500)]);
const merged = await loadUsageHistoryWithLive();
expect(merged).toHaveLength(1);
expect(merged[0]!.sessionId).toBe('sess-only');
expect(merged[0]!.models['qwen-max']!.totalTokens).toBe(500);
});
it('withLive: a corrupt usage_record.jsonl falls back to a full transcript replay', async () => {
// No usable persisted base (garbage file) — the loader must still surface
// the daemon transcript rather than returning nothing.
fs.writeFileSync(
path.join(process.env['QWEN_HOME']!, 'usage_record.jsonl'),
'{ this is not valid json\nalso broken}\n',
);
plantChatJsonl('sess-daemon', 1600);
const merged = await loadUsageHistoryWithLive();
expect(merged.map((r) => r.sessionId)).toEqual(['sess-daemon']);
});
});
describe('aggregateUsage — skills', () => {

View file

@ -16,6 +16,24 @@ import { createDebugLogger } from '../utils/debugLogger.js';
const debugLogger = createDebugLogger('USAGE_HISTORY');
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Trailing window used by {@link loadUsageHistoryWithLive} when merging
* non-persisted (daemon / Web Shell / in-progress) sessions into the history.
*
* Sized to cover the largest summary/daily range the usage-dashboard exposes
* (month = 30 days) plus margin, so the hero totals, breakdown tiles, and the
* per-day line/bar charts are exact. Crucially it is NOT sized for the full
* heatmap span (~12 months): persisted `usage_record.jsonl` records of any age
* are always unioned in, so the heatmap keeps its full history, but a *never-
* persisted* daemon session older than this window is not replayed that cell
* undercounts slightly. That cosmetic gap is a deliberate trade for load
* latency: replaying a full year of transcripts costs ~13s here vs. ~1.7s for
* this window (heavy Web Shell users accumulate thousands of unpersisted
* transcripts). Widen only alongside a cheaper scan.
*/
const LIVE_REBUILD_WINDOW_DAYS = 35;
export interface UsageSummaryRecord {
version: 1;
sessionId: string;
@ -204,10 +222,38 @@ export function metricsToUsageRecord(
};
}
interface RebuildFromSessionJsonlOptions {
/**
* Session to exclude from the one-time persistence migration (the caller's
* in-progress session {@link persistSessionUsage} writes its authoritative
* record on `/clear` or exit). It is still returned in the rebuilt records.
*/
skipSessionInRebuild?: string;
/** Persist rebuilt records as a migration. Read-only callers pass `false`. */
persist?: boolean;
/**
* Only replay transcripts whose file mtime is at/after this epoch-ms. Bounds
* the scan when merging recent live sessions into an already-persisted
* history (see {@link loadUsageHistoryWithLive}); undefined replays all.
*/
sinceMs?: number;
/**
* Session ids already covered by the persisted history. Their transcripts are
* skipped by filename (`{sessionId}.jsonl`) with no file read, avoiding a full
* replay of sessions the persisted file already records authoritatively.
*/
skipSessionIds?: ReadonlySet<string>;
}
async function rebuildFromSessionJsonl(
skipSessionInRebuild?: string,
persist = true,
options: RebuildFromSessionJsonlOptions = {},
): Promise<UsageSummaryRecord[]> {
const {
skipSessionInRebuild,
persist = true,
sinceMs,
skipSessionIds,
} = options;
const projectsDir = path.join(Storage.getGlobalQwenDir(), 'projects');
try {
if (!fs.existsSync(projectsDir)) return [];
@ -243,6 +289,31 @@ async function rebuildFromSessionJsonl(
for (const file of files) {
try {
const filePath = path.join(chatsDir, file);
// Bound the scan when merging live sessions into a persisted history:
// skip transcripts untouched before `sinceMs`.
if (sinceMs !== undefined) {
let mtimeMs: number;
try {
mtimeMs = fs.statSync(filePath).mtimeMs;
} catch (e) {
debugLogger.debug(
`rebuildFromSessionJsonl: cannot stat ${filePath}: ${e}`,
);
continue;
}
if (mtimeMs < sinceMs) continue;
}
// Skip sessions the persisted history already records, before any file
// read: the transcript filename is `{sessionId}.jsonl`
// (chatRecordingService.ts), so the sessionId — the same value the
// full-read path below derives from the first record — needs no I/O.
if (skipSessionIds && skipSessionIds.size > 0) {
const fileSessionId = path.basename(file, '.jsonl');
if (skipSessionIds.has(fileSessionId)) continue;
}
const records = await jsonl.read<ChatRecord>(filePath);
if (records.length === 0) continue;
@ -337,13 +408,73 @@ export async function loadUsageHistory(
}
return dedupBySessionId(
await rebuildFromSessionJsonl(
await rebuildFromSessionJsonl({
skipSessionInRebuild,
options?.persistRebuild ?? true,
),
persist: options?.persistRebuild ?? true,
}),
);
}
/**
* Load the durable usage history **and** merge in sessions that were never
* written to `usage_record.jsonl` notably daemon / Web Shell sessions (only
* the TUI `/clear` path persists usage) and any still-in-progress session.
*
* Unlike {@link loadUsageHistory}, which returns the persisted file verbatim
* whenever it is non-empty (and so silently omits everything not yet
* persisted), this replays recent transcripts for sessions the persisted file
* does not already cover and unions the two. Persisted records win on any
* sessionId conflict they are the authoritative final snapshot. This is what
* the daemon usage-dashboard reads so its totals reflect live Web Shell
* activity. Read-only: never writes `usage_record.jsonl`.
*
* The transcript scan is bounded to a trailing window (mtime-based) so an
* established history does not pay a full cross-project replay on every load.
*/
export async function loadUsageHistoryWithLive(options?: {
/**
* Only replay transcripts touched at/after this epoch-ms. Defaults to a
* {@link LIVE_REBUILD_WINDOW_DAYS}-day trailing window (covers the dashboard's
* summary + daily charts; see the constant for the heatmap trade-off).
*/
sinceMs?: number;
}): Promise<UsageSummaryRecord[]> {
let persisted: UsageSummaryRecord[] = [];
try {
const records = await jsonl.read<UsageSummaryRecord>(getUsageHistoryPath());
persisted = records.filter((r) => r.version === 1);
} catch (e) {
debugLogger.debug(
`loadUsageHistoryWithLive: failed to read usage file: ${e}`,
);
}
const persistedIds = new Set(persisted.map((r) => r.sessionId));
// The trailing window bounds an *incremental* live merge on top of persisted
// history: old days come from the persisted file, so only recent transcripts
// need replaying. When there is no persisted base (fresh machine, or a user
// who only ever ran Web Shell so `/clear` never persisted), nothing else
// covers older history — replay it all (unbounded) rather than silently
// truncating the dashboard, matching the pre-existing empty-file behavior.
const sinceMs =
options?.sinceMs ??
(persistedIds.size > 0
? Date.now() - LIVE_REBUILD_WINDOW_DAYS * MS_PER_DAY
: undefined);
const rebuilt = await rebuildFromSessionJsonl({
persist: false,
sinceMs,
skipSessionIds: persistedIds,
});
// Persisted records are the authoritative final snapshot, so they win on any
// sessionId conflict — place them last (dedupBySessionId is last-wins). The
// rebuilt set only adds sessions the persisted file never captured.
return dedupBySessionId([...rebuilt, ...persisted]);
}
export function getTimeRangeBounds(range: TimeRange): {
start: Date;
end: Date;