返回 DeepSeek-Reasonix
sessionDiagnostics.ts
根目录 / desktop / frontend / src / lib / sessionDiagnostics.ts
1 // sessionDiagnostics — content-free counters and last-event timings for the
2 // session-switch/history pipeline (Phase F of the session-switch/history
3 // refactor). Everything recorded here is bounded metadata (ids, phase
4 // durations, byte/entry counts, closed-class labels) — never message text —
5 // matching the crash/metrics privacy posture. crash.ts folds a snapshot into
6 // the performance-report context; the bench harness reads the same state via
7 // the window.__reasonixPerf hook (see installPerfDebugHook).
8 //
9 // This module must stay dependency-free: it sits at the bottom of the import
10 // graph so crash.ts (eager) and the lazy markdown-worker chunk can both use
11 // it without pulling each other in. Heavy/live state (transcript store cache
12 // weights, markdown worker counters) reaches it through registered providers.
13
14 export type ActivationOutcome = "ready" | "failed" | "cancelled";
15
16 export interface ActivationDiagnostic {
17 requestId: string;
18 tabId: string;
19 /** performance.now() clock; durations are differences on the same clock. */
20 requestedAtMs: number;
21 startedAtMs?: number;
22 settledAtMs?: number;
23 outcome?: ActivationOutcome;
24 failureClass?: string;
25 }
26
27 export interface NavigationDiagnostic {
28 intent: number;
29 tabId: string;
30 requestedAtMs: number;
31 identityPublishedAtMs?: number;
32 historyRequestedAtMs?: number;
33 historyReadableAtMs?: number;
34 runtimeReadyAtMs?: number;
35 firstPaintAtMs?: number;
36 composerEnabledAtMs?: number;
37 historyCacheHit?: boolean;
38 runtimeReattached?: boolean;
39 }
40
41 export interface HistoryPageDiagnostic {
42 entries: number;
43 inlineBytes: number;
44 durationMs: number;
45 stale: boolean;
46 source: string; // index|scan|live-index|live-fallback|resume-loaded|"" (unknown)
47 }
48
49 /** Backend cost breakdown for one session switch (desktop HistorySwitchPhases).
50 * Durations, counts, and byte sizes only — never session paths or message text. */
51 export interface HistorySwitchPhases {
52 resolveMs: number;
53 loadMs: number;
54 rebindMs: number;
55 historyMs: number;
56 totalMs: number;
57 loadedMessages: number;
58 loadedBytes: number;
59 historyEntries: number;
60 /** Full durable reads of the target session. One is correct; a switch that
61 * rebuilt its first screen from the log instead of the loaded transcript
62 * reports two, which is the duplicate load the benchmark fails on. */
63 durableReads: number;
64 outcome: string;
65 }
66
67 /** The message fields the inline byte total counts. */
68 interface HistoryInlineMessage {
69 content: string;
70 reasoning?: string;
71 detail?: string;
72 code?: string;
73 submitText?: string;
74 summary?: string;
75 archive?: string;
76 toolResultError?: string;
77 }
78
79 export interface MarkdownWorkerDiagnostic {
80 pending: number;
81 completed: number;
82 avgParseMs: number;
83 maxParseMs: number;
84 fallbackActive: boolean;
85 workerFailures: number;
86 }
87
88 export interface TranscriptCacheDiagnostic {
89 residentSessions: number;
90 maxResidentSessions: number;
91 bodyBytes: number;
92 bodyBudgetBytes: number;
93 markdownBytes: number;
94 markdownBudgetBytes: number;
95 historyEvictions: number;
96 markdownEvictions: number;
97 /** Pages of resident history the window budget has reclaimed. */
98 reclaimedPages: number;
99 /** Messages held across every resident window; the bounded reading cost. */
100 residentWindowEntries: number;
101 /** Adjacent pages the window keeps per session before reclaiming. */
102 windowMaxPages: number;
103 }
104
105 export interface MountedRowsDiagnostic {
106 mounted: number;
107 total: number;
108 }
109
110 export interface TranscriptRecoveryDiagnostic {
111 done: number;
112 cancelled: number;
113 expired: number;
114 lastOutcome?: "done" | "cancelled" | "expired";
115 lastReason?: string;
116 }
117
118 // activationFailureClass maps an activation error onto a closed label set so
119 // reports never carry the error text itself (which can echo session state).
120 export function activationFailureClass(error: string | undefined): string {
121 const low = (error ?? "").trim().toLowerCase();
122 if (!low) return "unknown";
123 if (low.includes("timeout") || low.includes("deadline")) return "timeout";
124 if (low.includes("cancel")) return "cancelled";
125 if (low.includes("stale") || low.includes("superseded")) return "stale";
126 if (low.includes("not found") || low.includes("no such") || low.includes("missing")) return "missing";
127 return "other";
128 }
129
130 const MAX_ACTIVATION_LOG = 128;
131 const MAX_NAVIGATION_LOG = 128;
132
133 const activations = new Map<string, ActivationDiagnostic>();
134 const activationOrder: string[] = [];
135 let lastActivationKey: string | null = null;
136 const navigations = new Map<number, NavigationDiagnostic>();
137 const navigationOrder: number[] = [];
138 let lastNavigationIntent: number | null = null;
139
140 let lastHistoryPage: HistoryPageDiagnostic | null = null;
141 let lastResumeHistory: HistoryPageDiagnostic | null = null;
142 let resumeSwitchPhases: HistorySwitchPhases | null = null;
143 let resumeSnapshotMs: number | undefined;
144 let historyPages = 0;
145 let historyStalePages = 0;
146 let historyIndexHits = 0;
147 let historyIndexMisses = 0;
148
149 let mountedRows: MountedRowsDiagnostic = { mounted: 0, total: 0 };
150
151 const transcriptRecovery: TranscriptRecoveryDiagnostic = { done: 0, cancelled: 0, expired: 0 };
152 let transcriptRecoverySeen = false;
153
154 type MarkdownWorkerProvider = () => MarkdownWorkerDiagnostic;
155 type TranscriptCacheProvider = () => TranscriptCacheDiagnostic;
156
157 let markdownWorkerProvider: MarkdownWorkerProvider | null = null;
158 let transcriptCacheProvider: TranscriptCacheProvider | null = null;
159
160 function now(): number {
161 return typeof performance !== "undefined" ? performance.now() : Date.now();
162 }
163
164 function trimActivationLog(): void {
165 while (activationOrder.length > MAX_ACTIVATION_LOG) {
166 const oldest = activationOrder.shift();
167 if (oldest) activations.delete(oldest);
168 }
169 }
170
171 function navigationEntry(intent: number): NavigationDiagnostic {
172 let entry = navigations.get(intent);
173 if (entry) return entry;
174 entry = { intent, tabId: "", requestedAtMs: now() };
175 navigations.set(intent, entry);
176 navigationOrder.push(intent);
177 lastNavigationIntent = intent;
178 while (navigationOrder.length > MAX_NAVIGATION_LOG) {
179 const oldest = navigationOrder.shift();
180 if (oldest !== undefined) navigations.delete(oldest);
181 }
182 return entry;
183 }
184
185 /** The user's local navigation intent was claimed. First writer wins so the
186 * desktop owner and controller can both report the boundary safely. */
187 export function noteNavigationRequested(intent: number): void {
188 if (!Number.isSafeInteger(intent) || intent < 0) return;
189 navigationEntry(intent);
190 }
191
192 export function noteNavigationIdentityPublished(intent: number, tabId: string): void {
193 const entry = navigationEntry(intent);
194 if (entry.identityPublishedAtMs === undefined) entry.identityPublishedAtMs = now();
195 if (tabId) entry.tabId = tabId;
196 }
197
198 export function noteNavigationHistoryRequested(intent: number, cacheHit: boolean): void {
199 const entry = navigationEntry(intent);
200 if (entry.historyRequestedAtMs === undefined) entry.historyRequestedAtMs = now();
201 entry.historyCacheHit = cacheHit;
202 }
203
204 export function noteNavigationHistoryReadable(intent: number, cacheHit: boolean): void {
205 const entry = navigationEntry(intent);
206 if (entry.historyReadableAtMs === undefined) entry.historyReadableAtMs = now();
207 entry.historyCacheHit = cacheHit;
208 }
209
210 export function noteNavigationRuntimeReady(intent: number, reattached = false): void {
211 const entry = navigationEntry(intent);
212 if (entry.runtimeReadyAtMs === undefined) entry.runtimeReadyAtMs = now();
213 entry.runtimeReattached = entry.runtimeReattached || reattached;
214 }
215
216 export function noteNavigationFirstPaint(intent: number): void {
217 const entry = navigationEntry(intent);
218 if (entry.firstPaintAtMs === undefined) entry.firstPaintAtMs = now();
219 }
220
221 /** Composer readiness is observed outside the controller. Attribute it to the
222 * newest navigation that published this tab identity. */
223 export function noteNavigationComposerEnabled(tabId: string): void {
224 for (let index = navigationOrder.length - 1; index >= 0; index -= 1) {
225 const entry = navigations.get(navigationOrder[index]);
226 if (!entry || entry.tabId !== tabId) continue;
227 if (entry.composerEnabledAtMs === undefined) entry.composerEnabledAtMs = now();
228 return;
229 }
230 }
231
232 /** A new ticketed activation (activateTopic) or tab switch was requested. */
233 export function noteActivationRequested(requestId: string): void {
234 if (!requestId || activations.has(requestId)) return;
235 activations.set(requestId, { requestId, tabId: "", requestedAtMs: now() });
236 activationOrder.push(requestId);
237 lastActivationKey = requestId;
238 trimActivationLog();
239 }
240
241 /** The backend echoed a different requestId than the provisional one: keep
242 * the original request timestamp under the canonical id. */
243 export function aliasActivationRequest(fromId: string, toId: string): void {
244 if (!fromId || !toId || fromId === toId) return;
245 const entry = activations.get(fromId);
246 if (!entry || activations.has(toId)) return;
247 activations.delete(fromId);
248 entry.requestId = toId;
249 activations.set(toId, entry);
250 const index = activationOrder.indexOf(fromId);
251 if (index >= 0) activationOrder[index] = toId;
252 if (lastActivationKey === fromId) lastActivationKey = toId;
253 }
254
255 /** The activation's "starting" phase was observed (or the switch applied). */
256 export function noteActivationStarted(requestId: string, tabId: string): void {
257 const entry = activations.get(requestId);
258 if (!entry || entry.startedAtMs !== undefined) return;
259 entry.startedAtMs = now();
260 if (tabId) entry.tabId = tabId;
261 }
262
263 /** The activation reached a terminal phase (ready / failed / cancelled). */
264 export function noteActivationSettled(requestId: string, outcome: ActivationOutcome, error?: string): void {
265 const entry = activations.get(requestId);
266 if (!entry || entry.outcome) return;
267 entry.settledAtMs = now();
268 entry.outcome = outcome;
269 if (outcome === "failed") entry.failureClass = activationFailureClass(error);
270 }
271
272 /** One HistorySliceForTab page response (stale retries recorded too). */
273 export function noteHistoryPage(page: HistoryPageDiagnostic): void {
274 historyPages += 1;
275 if (page.stale) historyStalePages += 1;
276 if (page.source === "index" || page.source === "live-index") historyIndexHits += 1;
277 else if (page.source === "scan" || page.source === "live-fallback") historyIndexMisses += 1;
278 lastHistoryPage = page;
279 }
280
281 export function beginResumeHistory(): void {
282 lastResumeHistory = null;
283 resumeSwitchPhases = null;
284 resumeSnapshotMs = undefined;
285 }
286
287 /** One ResumeSessionPage response built from the transcript the switch already
288 * loaded, plus the backend's phase breakdown for that switch. */
289 export function noteResumeHistoryPage(
290 page: { messages: readonly HistoryInlineMessage[]; switch?: HistorySwitchPhases | null },
291 durationMs: number,
292 snapshotMs?: number,
293 ): void {
294 let inlineBytes = 0;
295 for (const message of page.messages) {
296 inlineBytes += message.content.length + (message.reasoning?.length ?? 0)
297 + (message.detail?.length ?? 0) + (message.code?.length ?? 0)
298 + (message.submitText?.length ?? 0) + (message.summary?.length ?? 0)
299 + (message.archive?.length ?? 0) + (message.toolResultError?.length ?? 0);
300 }
301 lastResumeHistory = { entries: page.messages.length, inlineBytes, durationMs, stale: false, source: snapshotMs === undefined ? "resume-loaded" : "transcript-snapshot" };
302 resumeSwitchPhases = page.switch && Number.isSafeInteger(page.switch.durableReads) && page.switch.durableReads >= 0 ? { ...page.switch } : null;
303 resumeSnapshotMs = snapshotMs;
304 }
305
306 export function noteTranscriptFollowSwitch(phases: HistorySwitchPhases | void, metrics: { entries: number; inlineBytes: number }, durationMs: number, snapshotMs: number): void {
307 lastResumeHistory = { ...metrics, durationMs, stale: false, source: "transcript-v2" };
308 resumeSwitchPhases = phases ? { ...phases } : null;
309 resumeSnapshotMs = snapshotMs;
310 }
311
312 /** Durable reads a switch made beyond the one that produced its first screen. */
313 export function resumeSwitchDashboard(): { phases: HistorySwitchPhases | null; duplicateLoadCount: number | null } {
314 if (!resumeSwitchPhases) return { phases: null, duplicateLoadCount: null };
315 return { phases: { ...resumeSwitchPhases }, duplicateLoadCount: Math.max(0, resumeSwitchPhases.durableReads - 1) };
316 }
317
318 /** Current virtual-mounted vs total transcript row counts (Transcript.tsx). */
319 export function noteTranscriptRowCounts(mounted: number, total: number): void {
320 mountedRows = { mounted, total };
321 }
322
323 /** Terminal state of one transcript layout-recovery request (done /
324 * cancelled / expired), reported by the scroll arbiter (#8657). */
325 export function noteTranscriptRecoveryTerminal(state: { outcome: "done" | "cancelled" | "expired"; reason?: string }): void {
326 transcriptRecovery[state.outcome] += 1;
327 transcriptRecovery.lastOutcome = state.outcome;
328 transcriptRecovery.lastReason = state.reason;
329 transcriptRecoverySeen = true;
330 }
331
332 /** Registered by the lazy markdown-worker chunk at module load. */
333 export function registerMarkdownWorkerDiagnostics(provider: MarkdownWorkerProvider): void {
334 markdownWorkerProvider = provider;
335 }
336
337 /** Registered by transcriptStore at module load. */
338 export function registerTranscriptCacheDiagnostics(provider: TranscriptCacheProvider): void {
339 transcriptCacheProvider = provider;
340 }
341
342 export interface SessionPipelineDiagnostics {
343 activation?: ActivationDiagnostic & {
344 ticketToStartingMs?: number;
345 startingToReadyMs?: number;
346 totalMs?: number;
347 };
348 navigation?: NavigationDiagnostic & {
349 clickToIdentityMs?: number;
350 clickToFirstHistoryMs?: number;
351 clickToFirstPaintMs?: number;
352 clickToRuntimeReadyMs?: number;
353 clickToComposerEnabledMs?: number;
354 };
355 history?: HistoryPageDiagnostic & {
356 pages: number;
357 staleCount: number;
358 indexHits: number;
359 indexMisses: number;
360 };
361 resumeHistory?: HistoryPageDiagnostic;
362 resumeSwitch?: HistorySwitchPhases;
363 duplicateLoadCount: number | null;
364 resumeSnapshotMs?: number;
365 mountedRows?: MountedRowsDiagnostic;
366 transcriptRecovery?: TranscriptRecoveryDiagnostic;
367 markdownWorker?: MarkdownWorkerDiagnostic;
368 transcriptCache?: TranscriptCacheDiagnostic;
369 }
370
371 function deriveActivation(entry: ActivationDiagnostic): SessionPipelineDiagnostics["activation"] {
372 const out: SessionPipelineDiagnostics["activation"] = { ...entry };
373 if (entry.startedAtMs !== undefined) out.ticketToStartingMs = entry.startedAtMs - entry.requestedAtMs;
374 if (entry.settledAtMs !== undefined) {
375 out.totalMs = entry.settledAtMs - entry.requestedAtMs;
376 if (entry.outcome === "ready" && entry.startedAtMs !== undefined) {
377 out.startingToReadyMs = entry.settledAtMs - entry.startedAtMs;
378 }
379 }
380 return out;
381 }
382
383 function deriveNavigation(entry: NavigationDiagnostic): SessionPipelineDiagnostics["navigation"] {
384 const out: SessionPipelineDiagnostics["navigation"] = { ...entry };
385 if (entry.identityPublishedAtMs !== undefined) out.clickToIdentityMs = entry.identityPublishedAtMs - entry.requestedAtMs;
386 if (entry.historyReadableAtMs !== undefined) out.clickToFirstHistoryMs = entry.historyReadableAtMs - entry.requestedAtMs;
387 if (entry.firstPaintAtMs !== undefined) out.clickToFirstPaintMs = entry.firstPaintAtMs - entry.requestedAtMs;
388 if (entry.runtimeReadyAtMs !== undefined) out.clickToRuntimeReadyMs = entry.runtimeReadyAtMs - entry.requestedAtMs;
389 if (entry.composerEnabledAtMs !== undefined) out.clickToComposerEnabledMs = entry.composerEnabledAtMs - entry.requestedAtMs;
390 return out;
391 }
392
393 /** Point-in-time snapshot for the crash/performance report context. */
394 export function sessionPipelineDiagnostics(): SessionPipelineDiagnostics {
395 const out: SessionPipelineDiagnostics = { duplicateLoadCount: null };
396 const activation = lastActivationKey ? activations.get(lastActivationKey) : undefined;
397 if (activation) out.activation = deriveActivation(activation);
398 const navigation = lastNavigationIntent === null ? undefined : navigations.get(lastNavigationIntent);
399 if (navigation) out.navigation = deriveNavigation(navigation);
400 // A switch reports its first screen before any slice runs, so fall back to it
401 // instead of reporting no history at all.
402 const historyPage = lastHistoryPage ?? lastResumeHistory;
403 if (historyPage) {
404 out.history = {
405 ...historyPage,
406 pages: historyPages,
407 staleCount: historyStalePages,
408 indexHits: historyIndexHits,
409 indexMisses: historyIndexMisses,
410 };
411 }
412 if (lastResumeHistory) out.resumeHistory = { ...lastResumeHistory };
413 if (resumeSnapshotMs !== undefined) out.resumeSnapshotMs = resumeSnapshotMs;
414 const { phases, duplicateLoadCount } = resumeSwitchDashboard();
415 if (phases) out.resumeSwitch = phases;
416 out.duplicateLoadCount = duplicateLoadCount;
417 if (mountedRows.mounted > 0 || mountedRows.total > 0) out.mountedRows = { ...mountedRows };
418 if (transcriptRecoverySeen) out.transcriptRecovery = { ...transcriptRecovery };
419 if (markdownWorkerProvider) {
420 try {
421 out.markdownWorker = markdownWorkerProvider();
422 } catch {
423 // A broken provider must never break crash reporting.
424 }
425 }
426 if (transcriptCacheProvider) {
427 try {
428 out.transcriptCache = transcriptCacheProvider();
429 } catch {
430 // Same: diagnostics are best-effort.
431 }
432 }
433 return out;
434 }
435
436 /** Recent activation records, oldest first (bench harness introspection). */
437 export function activationLog(): ActivationDiagnostic[] {
438 const out: ActivationDiagnostic[] = [];
439 for (const requestId of activationOrder) {
440 const entry = activations.get(requestId);
441 if (entry) out.push({ ...entry });
442 }
443 return out;
444 }
445
446 /** Test/bench reset. */
447 export function resetSessionDiagnostics(): void {
448 activations.clear();
449 activationOrder.length = 0;
450 lastActivationKey = null;
451 navigations.clear();
452 navigationOrder.length = 0;
453 lastNavigationIntent = null;
454 lastHistoryPage = null;
455 lastResumeHistory = null;
456 resumeSwitchPhases = null;
457 resumeSnapshotMs = undefined;
458 historyPages = 0;
459 historyStalePages = 0;
460 historyIndexHits = 0;
461 historyIndexMisses = 0;
462 mountedRows = { mounted: 0, total: 0 };
463 transcriptRecovery.done = 0;
464 transcriptRecovery.cancelled = 0;
465 transcriptRecovery.expired = 0;
466 transcriptRecovery.lastOutcome = undefined;
467 transcriptRecovery.lastReason = undefined;
468 transcriptRecoverySeen = false;
469 }
470
470 lines TYPESCRIPT