diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 6768e081e2..286296b2ce 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -5,6 +5,7 @@ */ import fs from 'node:fs'; +import { persistUsageBeforeTranscriptDeletion } from './usageHistoryService.js'; import path from 'node:path'; import { Readable } from 'node:stream'; import { @@ -38,6 +39,9 @@ import { CompressionStatus } from '../core/turn.js'; import type { ChatRecord } from './chatRecordingService.js'; import * as jsonl from '../utils/jsonl-utils.js'; +vi.mock('./usageHistoryService.js', () => ({ + persistUsageBeforeTranscriptDeletion: vi.fn().mockResolvedValue(true), +})); vi.mock('node:path'); vi.mock('../utils/paths.js'); vi.mock('../utils/runtimeStatus.js'); @@ -1439,6 +1443,15 @@ describe('SessionService', () => { expect(result).toBe(true); expect(unlinkSyncSpy).toHaveBeenCalled(); + // #7384: the usage salvage must see the transcript BEFORE it is + // unlinked, or the summary is unrecoverable. + const salvage = vi.mocked(persistUsageBeforeTranscriptDeletion); + expect(salvage).toHaveBeenCalledWith( + expect.stringContaining(`${sessionIdA}.jsonl`), + ); + expect(salvage.mock.invocationCallOrder[0]!).toBeLessThan( + unlinkSyncSpy.mock.invocationCallOrder[0]!, + ); expect(rmSyncSpy).toHaveBeenCalledWith( expect.stringContaining(`file-history/${sessionIdA}`), { recursive: true, force: true }, diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index a17b659d75..4d9f17949b 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -5,6 +5,7 @@ */ import { Storage } from '../config/storage.js'; +import { persistUsageBeforeTranscriptDeletion } from './usageHistoryService.js'; import { getProjectHash } from '../utils/paths.js'; import path from 'node:path'; import fs from 'node:fs'; @@ -1341,6 +1342,10 @@ export class SessionService { const activePath = this.getSessionFilePath(sessionId, 'active'); const active = await this.readProjectSessionHead(sessionId, activePath); if (active) { + // #7384: the usage-history rebuild reads transcripts, so salvage + // the session's usage summary before the file is gone. Never + // blocks deletion (the salvage swallows its own errors). + await persistUsageBeforeTranscriptDeletion(activePath); this.removeFileIfExists(activePath); const archivedPath = this.getSessionFilePath(sessionId, 'archived'); if (fs.existsSync(archivedPath)) { @@ -1358,6 +1363,7 @@ export class SessionService { if (!archived) { return false; } + await persistUsageBeforeTranscriptDeletion(archivedPath); this.removeFileIfExists(archivedPath); this.removeWorktreeSidecars(sessionId); this.removeFileHistoryBackups(sessionId); diff --git a/packages/core/src/services/usageHistoryService.test.ts b/packages/core/src/services/usageHistoryService.test.ts index 01f22a9876..6d2ebd0dbd 100644 --- a/packages/core/src/services/usageHistoryService.test.ts +++ b/packages/core/src/services/usageHistoryService.test.ts @@ -14,6 +14,7 @@ import { loadUsageHistory, loadUsageHistoryWithLive, persistSessionUsage, + persistUsageBeforeTranscriptDeletion, } from './usageHistoryService.js'; import { ToolCallDecision } from '../telemetry/tool-call-decision.js'; import type { SessionMetrics } from '../telemetry/uiTelemetry.js'; @@ -777,6 +778,128 @@ describe('loadUsageHistory + persistSessionUsage (issue #4994 regression)', () = }); }); +// Regression for #7384: deleting a session erased its usage from the +// rebuild-from-transcript fallback forever. The salvage runs right before +// transcript deletion. +describe('persistUsageBeforeTranscriptDeletion (issue #7384)', () => { + let tmpHome: string; + let originalQwenHome: string | undefined; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-usage-salvage-')); + originalQwenHome = process.env['QWEN_HOME']; + process.env['QWEN_HOME'] = path.join(tmpHome, '.qwen'); + fs.mkdirSync(process.env['QWEN_HOME'], { recursive: true }); + }); + + afterEach(() => { + if (originalQwenHome === undefined) delete process.env['QWEN_HOME']; + else process.env['QWEN_HOME'] = originalQwenHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + function plantTranscript(sessionId: string, withTelemetry: boolean): string { + const dir = path.join( + process.env['QWEN_HOME']!, + 'projects', + 'salvage-project', + 'chats', + ); + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, `${sessionId}.jsonl`); + const start = new Date('2026-07-01T00:00:00Z').toISOString(); + const mid = new Date('2026-07-01T00:01:00Z').toISOString(); + const records: unknown[] = [ + { + sessionId, + cwd: '/salvage/project', + uuid: 'u1', + parentUuid: null, + timestamp: start, + type: 'user', + message: { role: 'user', content: 'hi' }, + }, + ]; + if (withTelemetry) { + records.push({ + sessionId, + cwd: '/salvage/project', + 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: 900, + input_token_count: 600, + output_token_count: 300, + cached_content_token_count: 0, + thoughts_token_count: 100, + total_token_count: 1000, + prompt_id: 'p1', + }, + }, + }); + } + fs.writeFileSync( + filePath, + records.map((r) => JSON.stringify(r)).join('\n') + '\n', + ); + return filePath; + } + + function usagePath(): string { + return path.join(process.env['QWEN_HOME']!, 'usage_record.jsonl'); + } + + it('writes the session summary before the transcript disappears', async () => { + const filePath = plantTranscript('sess-salvage-1', true); + await expect(persistUsageBeforeTranscriptDeletion(filePath)).resolves.toBe( + true, + ); + const lines = fs + .readFileSync(usagePath(), 'utf-8') + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + expect(lines).toHaveLength(1); + expect(lines[0].sessionId).toBe('sess-salvage-1'); + expect(lines[0].models['qwen-max'].totalTokens).toBe(1000); + expect(lines[0].project).toBe('/salvage/project'); + }); + + it('skips the write when the history already has the session (no #4994 duplicates)', async () => { + const filePath = plantTranscript('sess-salvage-2', true); + await persistUsageBeforeTranscriptDeletion(filePath); + await expect(persistUsageBeforeTranscriptDeletion(filePath)).resolves.toBe( + false, + ); + const lines = fs.readFileSync(usagePath(), 'utf-8').trim().split('\n'); + expect(lines).toHaveLength(1); + }); + + it('returns false for a transcript with no telemetry and writes nothing', async () => { + const filePath = plantTranscript('sess-salvage-3', false); + await expect(persistUsageBeforeTranscriptDeletion(filePath)).resolves.toBe( + false, + ); + expect(fs.existsSync(usagePath())).toBe(false); + }); + + it('never throws for a missing transcript', async () => { + await expect( + persistUsageBeforeTranscriptDeletion( + path.join(tmpHome, 'nope', 'missing.jsonl'), + ), + ).resolves.toBe(false); + }); +}); + describe('aggregateUsage — skills', () => { function skillRecord( sessionId: string, diff --git a/packages/core/src/services/usageHistoryService.ts b/packages/core/src/services/usageHistoryService.ts index 183d1da8f6..8b01b13ef1 100644 --- a/packages/core/src/services/usageHistoryService.ts +++ b/packages/core/src/services/usageHistoryService.ts @@ -222,6 +222,98 @@ export function metricsToUsageRecord( }; } +/** + * Replays a session transcript's `ui_telemetry` records into a usage + * summary. Returns null for an empty transcript; `record` is null when the + * transcript has no telemetry events (or unparseable timestamps) — the + * sessionId is still reported so callers can dedupe by session. + * + * Single implementation shared by the rebuild migration and the + * pre-deletion salvage (#7384) so the two can never drift. + */ +function summarizeTranscript( + records: ChatRecord[], +): { sessionId: string; record: UsageSummaryRecord | null } | null { + if (records.length === 0) return null; + const firstRecord = records[0]!; + const sessionId = firstRecord.sessionId; + if (!sessionId) return null; + const project = firstRecord.cwd; + + const telemetry = new UiTelemetryService(); + let hasEvents = false; + for (const record of records) { + if (record.type === 'system' && record.subtype === 'ui_telemetry') { + const payload = record.systemPayload as { uiEvent?: UiEvent } | undefined; + if (payload?.uiEvent) { + telemetry.addEvent(payload.uiEvent); + hasEvents = true; + } + } + } + if (!hasEvents) return { sessionId, record: null }; + + const startTime = new Date(firstRecord.timestamp).getTime(); + const endTime = new Date(records[records.length - 1]!.timestamp).getTime(); + if (isNaN(startTime) || isNaN(endTime)) { + return { sessionId, record: null }; + } + return { + sessionId, + record: metricsToUsageRecord( + sessionId, + project, + startTime, + endTime, + telemetry.getMetrics(), + ), + }; +} + +/** + * Salvages a session's usage summary into `usage_record.jsonl` right before + * its transcript is deleted (#7384): the usage-history rebuild reads + * transcripts, so deleting a session that was never `/clear`ed or cleanly + * exited previously erased its usage from the records forever. + * + * Never throws — deletion must proceed even when salvage fails — and skips + * the write when the persisted history already carries a record for the + * session (a `/clear` or exit already wrote the authoritative summary; + * duplicating it would re-open #4994). Returns true when a record was + * written. + */ +export async function persistUsageBeforeTranscriptDeletion( + transcriptPath: string, +): Promise { + try { + const records = await jsonl.read(transcriptPath); + const summarized = summarizeTranscript(records); + if (!summarized?.record) return false; + + const usagePath = getUsageHistoryPath(); + try { + if (fs.existsSync(usagePath)) { + const existing = await jsonl.read(usagePath); + if (existing.some((r) => r?.sessionId === summarized.sessionId)) { + return false; + } + } + } catch (e) { + // Unreadable history: write anyway — the read side dedupes by + // sessionId (last-wins), so a duplicate is bounded and preferable to + // silently losing the session's usage. + debugLogger.debug( + `persistUsageBeforeTranscriptDeletion: cannot read history: ${e}`, + ); + } + jsonl.writeLineSync(usagePath, summarized.record); + return true; + } catch (e) { + debugLogger.debug(`persistUsageBeforeTranscriptDeletion: ${e}`); + return false; + } +} + interface RebuildFromSessionJsonlOptions { /** * Session to exclude from the one-time persistence migration (the caller's @@ -315,45 +407,12 @@ async function rebuildFromSessionJsonl( } const records = await jsonl.read(filePath); - if (records.length === 0) continue; - - const firstRecord = records[0]!; - const sessionId = firstRecord.sessionId; - if (seenSessionIds.has(sessionId)) continue; - seenSessionIds.add(sessionId); - const project = firstRecord.cwd; - - const telemetry = new UiTelemetryService(); - let hasEvents = false; - - for (const record of records) { - if (record.type === 'system' && record.subtype === 'ui_telemetry') { - const payload = record.systemPayload as - | { uiEvent?: UiEvent } - | undefined; - if (payload?.uiEvent) { - telemetry.addEvent(payload.uiEvent); - hasEvents = true; - } - } - } - - if (!hasEvents) continue; - - const startTime = new Date(firstRecord.timestamp).getTime(); - const lastRecord = records[records.length - 1]!; - const endTime = new Date(lastRecord.timestamp).getTime(); - if (isNaN(startTime) || isNaN(endTime) || !sessionId) continue; - - results.push( - metricsToUsageRecord( - sessionId, - project, - startTime, - endTime, - telemetry.getMetrics(), - ), - ); + const summarized = summarizeTranscript(records); + if (!summarized) continue; + if (seenSessionIds.has(summarized.sessionId)) continue; + seenSessionIds.add(summarized.sessionId); + if (!summarized.record) continue; + results.push(summarized.record); } catch (e) { debugLogger.debug( `rebuildFromSessionJsonl: failed to process ${file}: ${e}`,