diff --git a/docs/org2-performance-guard-2026-08-04/SessionSwitchPerformanceTrace.md b/docs/org2-performance-guard-2026-08-04/SessionSwitchPerformanceTrace.md new file mode 100644 index 000000000..26d0ec0b6 --- /dev/null +++ b/docs/org2-performance-guard-2026-08-04/SessionSwitchPerformanceTrace.md @@ -0,0 +1,35 @@ +# Session switch performance trace audit + +## Scope + +Session-switch User Timing instrumentation across WorkStation tab focus, the +session pipeline, persisted-history hydration, Jotai state commit, and the first +paint after loaded state reaches `ChatView`. + +## Lifecycle matrix + +| Lifecycle | Expected behavior | Verification | Verdict | +| --- | --- | --- | --- | +| Mount / active switch | Start or join one process-local trace for the target session. | Unit coverage exercises start, join, stages, and completion. | keep | +| Repeated switch | Supersede the previous active trace and retain only the latest 20 completed traces. | Unit coverage asserts stale User Timing entries are cleared after trace 20. | keep | +| Paint | Schedule two animation frames only after the target session reports loaded. | Hook cleanup cancels both scheduled frame IDs. | keep | +| Abort / unmount | Abort cleanup finishes the matching active trace; stale session callbacks are ignored. | Session ID matching is enforced by every mark/finish operation. | keep | +| Idle / hidden | No timer, observer, listener, poller, worker, or subscription is created by the trace module. | Static inspection of the module and hook. | keep | +| Multi-instance | Trace state and browser User Timing entries are local to each WebView process. | No persisted or cross-window state is introduced. | keep | + +## Resource findings + +| Area | Finding | Verdict | Reason / mitigation | +| --- | --- | --- | --- | +| CPU | Each lifecycle stage adds a bounded number of User Timing marks/measures. | keep | Work only occurs during an explicit session switch; no idle loop exists. | +| Memory | One active trace plus 20 completed traces are retained. | keep | Expired entries are removed from both module state and the browser performance timeline. | +| Rendering | `ChatView` observes session ID and load status to finish the trace after paint. | keep with measurement required | The subscriptions are narrow, but their actual render cost still requires a desktop/WebView profile. | +| Cancellation | The paint hook cancels scheduled animation frames on dependency change or unmount. | keep | Prevents a stale component from completing a newer session trace. | +| Persistence / I/O | Trace data is not persisted and creates no network, filesystem, or database I/O. | keep | Data remains in browser developer tooling only. | + +## Verdict + +**Pass for bounded instrumentation; runtime measurement pending.** The trace is +lifecycle-safe by inspection and unit coverage and can ship independently because +the PR makes no speedup claim. A packaged desktop/WebView profile is still required +before using the resulting data to claim a runtime performance improvement. diff --git a/src/engines/ChatPanel/ChatView.tsx b/src/engines/ChatPanel/ChatView.tsx index ddba35ea6..ac3ccf773 100644 --- a/src/engines/ChatPanel/ChatView.tsx +++ b/src/engines/ChatPanel/ChatView.tsx @@ -31,9 +31,14 @@ import Message from "@src/components/Message"; import { useShowInteractArea } from "@src/contexts/workspace/ChatContext"; import { forkExternalHistoryIntoOrgiiSession } from "@src/engines/ChatPanel/externalHistoryFork"; import { derivedSnapshotAtom } from "@src/engines/SessionCore/core/atoms/events"; +import { + loadStatusAtom, + sessionIdAtom, +} from "@src/engines/SessionCore/core/atoms/metadata"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { derivePlanApprovalViewState } from "@src/engines/SessionCore/derived/planDisplayEvents"; import { useTodoSync } from "@src/engines/SessionCore/hooks/session/useTodoSync"; +import { useSessionSwitchPaintTrace } from "@src/engines/SessionCore/performance/useSessionSwitchPaintTrace"; import { ForkCancelledError } from "@src/features/TeamCollaboration/forkSession"; import { useFileReviewSync } from "@src/hooks/fileReview"; import { createLogger } from "@src/hooks/logger"; @@ -257,6 +262,12 @@ const ChatView: React.FC = memo( const streamRetry = streamRetryStatus?.sessionId === sessionId ? streamRetryStatus : null; const snapshot = useAtomValue(derivedSnapshotAtom); + const loadedSessionId = useAtomValue(sessionIdAtom); + const loadStatus = useAtomValue(loadStatusAtom); + useSessionSwitchPaintTrace( + sessionId, + loadedSessionId === sessionId && loadStatus === "loaded" + ); const canvasPreviewPill = useChatViewCanvasPreview(sessionId, snapshot); const currentPlanApproval = usePendingPlanApproval(sessionId); const chatEvents = snapshot?.chatEvents ?? EMPTY_CHAT_EVENTS; diff --git a/src/engines/SessionCore/core/atoms/actions.ts b/src/engines/SessionCore/core/atoms/actions.ts index c8a36155c..0cbb830a9 100644 --- a/src/engines/SessionCore/core/atoms/actions.ts +++ b/src/engines/SessionCore/core/atoms/actions.ts @@ -14,6 +14,7 @@ import { atom } from "jotai"; import { REPLAY_CONFIG } from "@src/config/workspace/replayConfig"; import { clearLoadedPayloads } from "@src/engines/SessionCore/payloads"; +import { markSessionSwitchTrace } from "@src/engines/SessionCore/performance/sessionSwitchPerformance"; import { clearLoadedTurnRegistry } from "@src/engines/SessionCore/turns/loadedTurnRegistry"; import { createLogger } from "@src/hooks/logger"; import { messageQueueAtom } from "@src/store/ui/messageQueueAtom"; @@ -158,6 +159,11 @@ export const loadSessionAtom = atom( isFromCache = false, replace = false, } = payload; + markSessionSwitchTrace(sessionId, "state-commit-start", { + eventCount: events.length, + fromCache: isFromCache, + replace, + }); // Preserve synthetic user events (injected by session launch) when the // sync hooks reload from SQLite/API before the backend has persisted the @@ -445,6 +451,9 @@ export const loadSessionAtom = atom( set(replayBarValueAtom, REPLAY_CONFIG.MAX_VALUE); set(replayModeAtom, "follow"); } + markSessionSwitchTrace(sessionId, "state-commit-complete", { + eventCount: mergedEvents.length, + }); } ); loadSessionAtom.debugLabel = "session/load"; diff --git a/src/engines/SessionCore/performance/sessionSwitchPerformance.test.ts b/src/engines/SessionCore/performance/sessionSwitchPerformance.test.ts new file mode 100644 index 000000000..7921b70ca --- /dev/null +++ b/src/engines/SessionCore/performance/sessionSwitchPerformance.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + SESSION_SWITCH_PERFORMANCE_PREFIX, + finishSessionSwitchTrace, + markSessionSwitchTrace, + resetSessionSwitchPerformanceForTests, + startSessionSwitchTrace, +} from "./sessionSwitchPerformance"; + +interface UserTimingCall { + name: string; + options?: unknown; +} + +describe("session switch performance traces", () => { + const marks: UserTimingCall[] = []; + const measures: UserTimingCall[] = []; + const clearMarks = vi.fn(); + const clearMeasures = vi.fn(); + + beforeEach(() => { + marks.length = 0; + measures.length = 0; + clearMarks.mockReset(); + clearMeasures.mockReset(); + vi.stubGlobal("performance", { + clearMarks, + clearMeasures, + mark: (name: string, options?: unknown) => { + marks.push({ name, options }); + }, + measure: (name: string, options?: unknown) => { + measures.push({ name, options }); + }, + }); + resetSessionSwitchPerformanceForTests(); + }); + + afterEach(() => { + resetSessionSwitchPerformanceForTests(); + vi.unstubAllGlobals(); + }); + + it("records stage segments and a final painted measure", () => { + startSessionSwitchTrace("session-a", "session-jump"); + markSessionSwitchTrace("session-a", "state-cleared"); + markSessionSwitchTrace("session-a", "rust-switch-complete", { + cacheHit: true, + }); + finishSessionSwitchTrace("session-a", "painted"); + + expect(marks.map(({ name }) => name)).toEqual([ + `${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:mark:start`, + `${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:mark:state-cleared`, + `${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:mark:rust-switch-complete`, + `${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:mark:painted`, + ]); + expect(measures.map(({ name }) => name)).toContain( + `${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:total-to:painted` + ); + expect(marks[2].options).toMatchObject({ + detail: { + cacheHit: true, + sessionId: "session-a", + stage: "rust-switch-complete", + }, + }); + }); + + it("joins the click trace when the pipeline effect sees the same session", () => { + const clickTrace = startSessionSwitchTrace("session-a", "workstation-tab"); + const pipelineTrace = startSessionSwitchTrace( + "session-a", + "pipeline-effect", + { joinExisting: true } + ); + + expect(pipelineTrace).toBe(clickTrace); + expect(marks).toHaveLength(1); + }); + + it("drops late stages from a superseded session", () => { + startSessionSwitchTrace("session-a", "session-jump"); + startSessionSwitchTrace("session-b", "session-jump"); + + markSessionSwitchTrace("session-a", "rust-switch-complete"); + markSessionSwitchTrace("session-b", "rust-switch-complete"); + + expect(marks.map(({ name }) => name)).toEqual([ + `${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:mark:start`, + `${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:mark:superseded`, + `${SESSION_SWITCH_PERFORMANCE_PREFIX}:000002:mark:start`, + `${SESSION_SWITCH_PERFORMANCE_PREFIX}:000002:mark:rust-switch-complete`, + ]); + }); + + it("bounds retained performance entries to the latest twenty traces", () => { + for (let index = 0; index < 21; index += 1) { + const sessionId = `session-${index}`; + startSessionSwitchTrace(sessionId, "session-jump"); + finishSessionSwitchTrace(sessionId, "painted"); + } + + expect(clearMarks).toHaveBeenCalledWith( + `${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:mark:start` + ); + expect(clearMeasures).toHaveBeenCalledWith( + `${SESSION_SWITCH_PERFORMANCE_PREFIX}:000001:total-to:painted` + ); + }); +}); diff --git a/src/engines/SessionCore/performance/sessionSwitchPerformance.ts b/src/engines/SessionCore/performance/sessionSwitchPerformance.ts new file mode 100644 index 000000000..b2133729a --- /dev/null +++ b/src/engines/SessionCore/performance/sessionSwitchPerformance.ts @@ -0,0 +1,274 @@ +/** + * User Timing instrumentation for the singleton session pipeline. + * + * The marks stay available in browser/WebView performance tooling, but their + * retention is deliberately bounded: one active trace plus the latest + * completed traces. This module never persists data and never starts a timer, + * observer, subscription, or background loop. + */ + +export const SESSION_SWITCH_PERFORMANCE_PREFIX = "orgii:session-switch"; + +const MAX_COMPLETED_TRACES = 20; +const JOIN_EXISTING_TRACE_WINDOW_MS = 10_000; + +export type SessionSwitchTraceSource = + | "pipeline-effect" + | "secondary-claim" + | "session-jump" + | "workstation-tab"; + +export type SessionSwitchTraceStage = + | "data-ready" + | "display-events-read" + | "event-handler-ready" + | "memory-events-read" + | "no-adapter-load-start" + | "orchestrator-start" + | "persisted-history-complete" + | "pipeline-selected" + | "post-load-complete" + | "rust-hydration-complete" + | "rust-switch-complete" + | "state-clear-start" + | "state-cleared" + | "state-commit-complete" + | "state-commit-start" + | "switch-state-reset" + | "turn-window-complete" + | "workstation-focus-persisted"; + +export type SessionSwitchTraceOutcome = + | "aborted" + | "failed" + | "painted" + | "superseded"; + +type TraceDetail = Record; + +interface SessionSwitchTrace { + id: string; + sessionId: string; + source: SessionSwitchTraceSource; + startMarkName: string; + lastMarkName: string; + entryNames: Set; + stageCounts: Map; + startedAtMs: number; +} + +let traceSequence = 0; +let activeTrace: SessionSwitchTrace | null = null; +const completedTraces: SessionSwitchTrace[] = []; + +function getUserTiming(): Performance | null { + if (typeof performance === "undefined") return null; + if ( + typeof performance.mark !== "function" || + typeof performance.measure !== "function" + ) { + return null; + } + return performance; +} + +function createEntryDetail( + trace: SessionSwitchTrace, + stage: string, + detail?: TraceDetail +): TraceDetail { + return { + traceId: trace.id, + sessionId: trace.sessionId, + source: trace.source, + stage, + ...detail, + }; +} + +function safeMark( + timing: Performance, + name: string, + detail: TraceDetail +): boolean { + try { + timing.mark(name, { detail }); + return true; + } catch { + try { + timing.mark(name); + return true; + } catch { + return false; + } + } +} + +function safeMeasure( + timing: Performance, + name: string, + start: string, + end: string, + detail: TraceDetail +): boolean { + try { + timing.measure(name, { start, end, detail }); + return true; + } catch { + try { + timing.measure(name, start, end); + return true; + } catch { + return false; + } + } +} + +function clearTraceEntries(trace: SessionSwitchTrace): void { + const timing = getUserTiming(); + if (!timing) return; + for (const name of trace.entryNames) { + timing.clearMarks?.(name); + timing.clearMeasures?.(name); + } +} + +function retainCompletedTrace(trace: SessionSwitchTrace): void { + completedTraces.push(trace); + while (completedTraces.length > MAX_COMPLETED_TRACES) { + const expired = completedTraces.shift(); + if (expired) clearTraceEntries(expired); + } +} + +function recordStage( + trace: SessionSwitchTrace, + stage: string, + detail?: TraceDetail +): void { + const timing = getUserTiming(); + if (!timing) return; + + const occurrence = (trace.stageCounts.get(stage) ?? 0) + 1; + trace.stageCounts.set(stage, occurrence); + const stageKey = occurrence === 1 ? stage : `${stage}-${occurrence}`; + const markName = `${SESSION_SWITCH_PERFORMANCE_PREFIX}:${trace.id}:mark:${stageKey}`; + const entryDetail = createEntryDetail(trace, stage, detail); + if (!safeMark(timing, markName, entryDetail)) return; + trace.entryNames.add(markName); + + const totalMeasureName = `${SESSION_SWITCH_PERFORMANCE_PREFIX}:${trace.id}:total-to:${stageKey}`; + if ( + safeMeasure( + timing, + totalMeasureName, + trace.startMarkName, + markName, + entryDetail + ) + ) { + trace.entryNames.add(totalMeasureName); + } + + const segmentMeasureName = `${SESSION_SWITCH_PERFORMANCE_PREFIX}:${trace.id}:segment:${stageKey}`; + if ( + safeMeasure( + timing, + segmentMeasureName, + trace.lastMarkName, + markName, + entryDetail + ) + ) { + trace.entryNames.add(segmentMeasureName); + } + trace.lastMarkName = markName; +} + +function finishTrace( + trace: SessionSwitchTrace, + outcome: SessionSwitchTraceOutcome, + detail?: TraceDetail +): void { + recordStage(trace, outcome, detail); + if (activeTrace === trace) activeTrace = null; + retainCompletedTrace(trace); +} + +/** + * Start a new trace, or join the active trace when a later lifecycle owner + * sees the same session switch (for example WorkStation click → ChatView claim). + */ +export function startSessionSwitchTrace( + sessionId: string, + source: SessionSwitchTraceSource, + options: { joinExisting?: boolean } = {} +): string | null { + const timing = getUserTiming(); + if (!timing || !sessionId) return null; + + if ( + options.joinExisting && + activeTrace?.sessionId === sessionId && + Date.now() - activeTrace.startedAtMs <= JOIN_EXISTING_TRACE_WINDOW_MS + ) { + return activeTrace.id; + } + + if (activeTrace) { + finishTrace(activeTrace, "superseded", { nextSessionId: sessionId }); + } + + traceSequence += 1; + const id = String(traceSequence).padStart(6, "0"); + const startMarkName = `${SESSION_SWITCH_PERFORMANCE_PREFIX}:${id}:mark:start`; + const trace: SessionSwitchTrace = { + id, + sessionId, + source, + startMarkName, + lastMarkName: startMarkName, + entryNames: new Set([startMarkName]), + stageCounts: new Map(), + startedAtMs: Date.now(), + }; + const marked = safeMark( + timing, + startMarkName, + createEntryDetail(trace, "start") + ); + if (!marked) return null; + activeTrace = trace; + return id; +} + +export function hasActiveSessionSwitchTrace(sessionId: string): boolean { + return activeTrace?.sessionId === sessionId; +} + +export function markSessionSwitchTrace( + sessionId: string, + stage: SessionSwitchTraceStage, + detail?: TraceDetail +): void { + if (!activeTrace || activeTrace.sessionId !== sessionId) return; + recordStage(activeTrace, stage, detail); +} + +export function finishSessionSwitchTrace( + sessionId: string, + outcome: SessionSwitchTraceOutcome, + detail?: TraceDetail +): void { + if (!activeTrace || activeTrace.sessionId !== sessionId) return; + finishTrace(activeTrace, outcome, detail); +} + +/** Test-only reset for deterministic module-global retention assertions. */ +export function resetSessionSwitchPerformanceForTests(): void { + if (activeTrace) clearTraceEntries(activeTrace); + for (const trace of completedTraces) clearTraceEntries(trace); + activeTrace = null; + completedTraces.length = 0; + traceSequence = 0; +} diff --git a/src/engines/SessionCore/performance/useSessionSwitchPaintTrace.ts b/src/engines/SessionCore/performance/useSessionSwitchPaintTrace.ts new file mode 100644 index 000000000..fe4cad41e --- /dev/null +++ b/src/engines/SessionCore/performance/useSessionSwitchPaintTrace.ts @@ -0,0 +1,38 @@ +import { useEffect } from "react"; + +import { + finishSessionSwitchTrace, + hasActiveSessionSwitchTrace, +} from "./sessionSwitchPerformance"; + +/** + * Finish a session-switch trace after React has committed loaded state and the + * browser has crossed two animation frames, making the measure a useful proxy + * for click-to-paint instead of merely click-to-state-update. + */ +export function useSessionSwitchPaintTrace( + sessionId: string, + loaded: boolean +): void { + useEffect(() => { + if (!loaded || !hasActiveSessionSwitchTrace(sessionId)) return; + if ( + typeof requestAnimationFrame !== "function" || + typeof cancelAnimationFrame !== "function" + ) { + return; + } + + let paintFrame = 0; + const commitFrame = requestAnimationFrame(() => { + paintFrame = requestAnimationFrame(() => { + finishSessionSwitchTrace(sessionId, "painted"); + }); + }); + + return () => { + cancelAnimationFrame(commitFrame); + if (paintFrame !== 0) cancelAnimationFrame(paintFrame); + }; + }, [loaded, sessionId]); +} diff --git a/src/engines/SessionCore/sync/sessionSwitchEffectRunner.ts b/src/engines/SessionCore/sync/sessionSwitchEffectRunner.ts index 263884863..d5e0c3180 100644 --- a/src/engines/SessionCore/sync/sessionSwitchEffectRunner.ts +++ b/src/engines/SessionCore/sync/sessionSwitchEffectRunner.ts @@ -1,3 +1,8 @@ +import { + finishSessionSwitchTrace, + markSessionSwitchTrace, + startSessionSwitchTrace, +} from "../performance/sessionSwitchPerformance"; import { runSessionSwitchOrchestrator } from "./sessionSwitchOrchestrator"; import { disposeCurrentHandler, @@ -43,18 +48,30 @@ export function runSessionSwitchEffect( logger, } = options; + startSessionSwitchTrace(sessionId, "pipeline-effect", { + joinExisting: true, + }); const leavingSessionId = refs.prevSessionIdRef.current; refs.prevSessionIdRef.current = sessionId; refs.prevReloadEpochRef.current = reloadEpoch; resetSessionSwitchState(switchActions, sessionId, leavingSessionId); disposeCurrentHandler(refs); + markSessionSwitchTrace(sessionId, "switch-state-reset", { + leavingSessionId, + reloadEpoch, + }); const adapter = getAdapterForSession(sessionId); const abortController = new AbortController(); if (!adapter) { loadSessionWithoutAdapter(sessionId, abortController, loadActions, logger); - return () => abortController.abort(); + return () => { + abortController.abort(); + finishSessionSwitchTrace(sessionId, "aborted", { + reason: "sync-effect-cleanup", + }); + }; } refs.adapterRef.current = adapter; @@ -66,6 +83,9 @@ export function runSessionSwitchEffect( logStatusChange ) ); + markSessionSwitchTrace(sessionId, "event-handler-ready", { + adapterCategory: adapter.category, + }); runSessionSwitchOrchestrator({ sessionId, @@ -79,6 +99,9 @@ export function runSessionSwitchEffect( return () => { abortController.abort(); + finishSessionSwitchTrace(sessionId, "aborted", { + reason: "sync-effect-cleanup", + }); resetReloadGuardForSession(sessionId, refs); }; } diff --git a/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts b/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts index 028758419..f83f972dc 100644 --- a/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts +++ b/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts @@ -2,6 +2,10 @@ import { cursorIdeComposerLastUpdatedAt } from "@src/api/tauri/externalHistory/c import { Message } from "@src/components/Message"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import { isVisibleInChat } from "@src/engines/SessionCore/ingestion/visibilityFilters"; +import { + finishSessionSwitchTrace, + markSessionSwitchTrace, +} from "@src/engines/SessionCore/performance/sessionSwitchPerformance"; import type { Logger } from "@src/hooks/logger"; import { composerIdFromSessionId, @@ -49,7 +53,13 @@ export function runSessionSwitchOrchestrator( } = options; try { + markSessionSwitchTrace(sessionId, "orchestrator-start", { + adapterCategory: adapter.category, + }); const cacheHit = await eventStoreProxy.switchSession(sessionId); + markSessionSwitchTrace(sessionId, "rust-switch-complete", { + cacheHit, + }); if (abortController.signal.aborted) return; if (cacheHit) { await handleCacheHit({ @@ -74,6 +84,9 @@ export function runSessionSwitchOrchestrator( } catch (error) { if (!options.abortController.signal.aborted) { const detail = error instanceof Error ? error.message : String(error); + finishSessionSwitchTrace(options.sessionId, "failed", { + reason: "orchestrator-error", + }); options.logger.error( `failed to load history for ${options.sessionId}:`, error @@ -125,10 +138,17 @@ async function handleCacheHit( const postResult = adapter.postLoad ? await adapter.postLoad(sessionId, abortController.signal) : null; + markSessionSwitchTrace(sessionId, "post-load-complete", { + hasPostLoad: Boolean(adapter.postLoad), + runStatus: postResult?.runStatus, + }); if (abortController.signal.aborted) return; const cacheHitInFlight = isInFlightRunStatus(postResult?.runStatus); let displayEvents = await eventStoreProxy.getEvents(sessionId); + markSessionSwitchTrace(sessionId, "memory-events-read", { + eventCount: displayEvents.length, + }); if (abortController.signal.aborted) return; if (!cacheHitInFlight) { @@ -137,8 +157,12 @@ async function handleCacheHit( sessionId, isCollaborationImportedSession(sessionId) ? 0 : undefined ); + markSessionSwitchTrace(sessionId, "turn-window-complete"); if (abortController.signal.aborted) return; displayEvents = await eventStoreProxy.getEvents(sessionId); + markSessionSwitchTrace(sessionId, "display-events-read", { + eventCount: displayEvents.length, + }); // The round-window load can resolve to zero chat-visible events when // the turn index is mid-rebuild (e.g. switching into a session right // after it finished a long run), and `set_round_window` overwrites the @@ -152,9 +176,16 @@ async function handleCacheHit( sessionId, abortController.signal ); + markSessionSwitchTrace(sessionId, "persisted-history-complete", { + eventCount: fallbackEvents.length, + reason: "empty-turn-window", + }); if (abortController.signal.aborted) return; if (fallbackEvents.length > 0) { await hydrateSessionStoreBeforeDisplay(sessionId, fallbackEvents); + markSessionSwitchTrace(sessionId, "rust-hydration-complete", { + eventCount: fallbackEvents.length, + }); if (abortController.signal.aborted) return; displayEvents = fallbackEvents; } @@ -168,12 +199,24 @@ async function handleCacheHit( sessionId, abortController.signal ); + markSessionSwitchTrace(sessionId, "persisted-history-complete", { + eventCount: displayEvents.length, + reason: "empty-resident-store", + }); if (abortController.signal.aborted) return; await hydrateSessionStoreBeforeDisplay(sessionId, displayEvents); + markSessionSwitchTrace(sessionId, "rust-hydration-complete", { + eventCount: displayEvents.length, + }); } if (abortController.signal.aborted) return; } + markSessionSwitchTrace(sessionId, "data-ready", { + cacheHit: true, + eventCount: displayEvents.length, + inFlight: cacheHitInFlight, + }); actions.dispatchLoadSession({ sessionId, events: displayEvents, @@ -212,15 +255,32 @@ async function handleCursorIdeCacheHit( const cachedUpdatedAt = getCursorIdeSnapshotLastUpdatedAt(sessionId); if (currentUpdatedAt !== null && cachedUpdatedAt === currentUpdatedAt) { const cachedEvents = await eventStoreProxy.getEvents(); + markSessionSwitchTrace(sessionId, "display-events-read", { + eventCount: cachedEvents.length, + }); if (abortController.signal.aborted) return true; + markSessionSwitchTrace(sessionId, "data-ready", { + cacheHit: true, + eventCount: cachedEvents.length, + }); actions.dispatchLoadSession({ sessionId, events: cachedEvents }); return true; } const events = await adapter.loadHistory(sessionId, abortController.signal); + markSessionSwitchTrace(sessionId, "persisted-history-complete", { + eventCount: events.length, + }); if (abortController.signal.aborted) return true; await eventStoreProxy.set(events, sessionId); + markSessionSwitchTrace(sessionId, "rust-hydration-complete", { + eventCount: events.length, + }); if (abortController.signal.aborted) return true; + markSessionSwitchTrace(sessionId, "data-ready", { + cacheHit: false, + eventCount: events.length, + }); actions.dispatchLoadSession({ sessionId, events }); return true; } @@ -250,20 +310,37 @@ async function handleCacheMiss( const missPostResult = adapter.postLoad ? await adapter.postLoad(sessionId, abortController.signal) : null; + markSessionSwitchTrace(sessionId, "post-load-complete", { + hasPostLoad: Boolean(adapter.postLoad), + runStatus: missPostResult?.runStatus, + }); if (abortController.signal.aborted) return; const missInFlight = isInFlightRunStatus(missPostResult?.runStatus); const events = !missInFlight ? await loadPersistedHistory(adapter, sessionId, abortController.signal) : await adapter.loadHistory(sessionId, abortController.signal); + markSessionSwitchTrace(sessionId, "persisted-history-complete", { + eventCount: events.length, + inFlight: missInFlight, + }); if (abortController.signal.aborted) return; await hydrateSessionStoreBeforeDisplay( sessionId, events, missInFlight ? "merge" : "replace" ); + markSessionSwitchTrace(sessionId, "rust-hydration-complete", { + eventCount: events.length, + mode: missInFlight ? "merge" : "replace", + }); if (abortController.signal.aborted) return; + markSessionSwitchTrace(sessionId, "data-ready", { + cacheHit: false, + eventCount: events.length, + inFlight: missInFlight, + }); actions.dispatchLoadSession({ sessionId, events }); if ( missInFlight && diff --git a/src/engines/SessionCore/sync/sessionSyncNoAdapterLoader.ts b/src/engines/SessionCore/sync/sessionSyncNoAdapterLoader.ts index f8b99fce5..69c4c7b1d 100644 --- a/src/engines/SessionCore/sync/sessionSyncNoAdapterLoader.ts +++ b/src/engines/SessionCore/sync/sessionSyncNoAdapterLoader.ts @@ -1,5 +1,9 @@ import { Message } from "@src/components/Message"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import { + finishSessionSwitchTrace, + markSessionSwitchTrace, +} from "@src/engines/SessionCore/performance/sessionSwitchPerformance"; import type { Logger } from "@src/hooks/logger"; import type { SessionLoadStateActions } from "./sessionSyncStateHelpers"; @@ -21,19 +25,35 @@ export function loadSessionWithoutAdapter( logger: Logger ): void { const loadHistory = async () => { + markSessionSwitchTrace(sessionId, "no-adapter-load-start"); actions.setLoadStatus("loading"); try { - await eventStoreProxy.switchSession(sessionId); + const cacheHit = await eventStoreProxy.switchSession(sessionId); + markSessionSwitchTrace(sessionId, "rust-switch-complete", { + cacheHit, + }); if (abortController.signal.aborted) return; const events = await loadOwnSessionInitialEvents(sessionId); + markSessionSwitchTrace(sessionId, "persisted-history-complete", { + eventCount: events.length, + }); if (abortController.signal.aborted) return; await hydrateSessionStoreBeforeDisplay(sessionId, events); + markSessionSwitchTrace(sessionId, "rust-hydration-complete", { + eventCount: events.length, + }); if (abortController.signal.aborted) return; + markSessionSwitchTrace(sessionId, "data-ready", { + eventCount: events.length, + }); actions.dispatchLoadSession({ sessionId, events }); actions.setWpReadOnly(true); } catch (error) { if (abortController.signal.aborted) return; const detail = error instanceof Error ? error.message : String(error); + finishSessionSwitchTrace(sessionId, "failed", { + reason: "no-adapter-load-error", + }); logger.error(`failed to load session (no adapter) ${sessionId}:`, error); actions.failSessionLoad(detail); actions.setWpReadOnly(true); diff --git a/src/store/session/viewAtom.ts b/src/store/session/viewAtom.ts index 5323b7853..bd7cff9f6 100644 --- a/src/store/session/viewAtom.ts +++ b/src/store/session/viewAtom.ts @@ -38,6 +38,10 @@ import { sessionIdAtom, triggerSessionReloadAtom, } from "@src/engines/SessionCore/core/atoms/metadata"; +import { + markSessionSwitchTrace, + startSessionSwitchTrace, +} from "@src/engines/SessionCore/performance/sessionSwitchPerformance"; import { registerLiveSubagentSignalAtom, registerRuntimeStatusGateSessionAtoms, @@ -235,9 +239,15 @@ export const claimPipelineSessionAtom = atom( (get, set, sessionId: string) => { const previousPipelineSessionId = get(activeSessionIdAtom); + startSessionSwitchTrace(sessionId, "secondary-claim", { + joinExisting: true, + }); + markSessionSwitchTrace(sessionId, "state-clear-start"); set(clearSessionAtom); + markSessionSwitchTrace(sessionId, "state-cleared"); set(loadStatusAtom, "loading"); set(activeSessionIdAtom, sessionId); + markSessionSwitchTrace(sessionId, "pipeline-selected"); if (previousPipelineSessionId === sessionId) { set(triggerSessionReloadAtom, sessionId); } @@ -288,7 +298,14 @@ export const jumpToSessionAtom = atom( const sessionId = isRich ? payload.sessionId : payload; const previousPipelineSessionId = get(activeSessionIdAtom); + if (sessionId) { + startSessionSwitchTrace(sessionId, "session-jump"); + markSessionSwitchTrace(sessionId, "state-clear-start"); + } set(clearSessionAtom); + if (sessionId) { + markSessionSwitchTrace(sessionId, "state-cleared"); + } set(loadStatusAtom, sessionId ? "loading" : "idle"); // WorkStation owns the navigation, so update its memory atom AND // the pipeline atom in a single underlying-storage write. When @@ -302,6 +319,9 @@ export const jumpToSessionAtom = atom( repoPath: isRich ? payload.repoPath : current.repoPath, }); set(activeSessionIdAtom, sessionId); + if (sessionId) { + markSessionSwitchTrace(sessionId, "pipeline-selected"); + } if (sessionId && previousPipelineSessionId === sessionId) { set(triggerSessionReloadAtom, sessionId); } diff --git a/src/store/workstation/tabRegistry/atoms.ts b/src/store/workstation/tabRegistry/atoms.ts index 93e07ed4f..69f1ddd30 100644 --- a/src/store/workstation/tabRegistry/atoms.ts +++ b/src/store/workstation/tabRegistry/atoms.ts @@ -9,6 +9,11 @@ */ import { type Getter, type Setter, atom } from "jotai"; +import { + markSessionSwitchTrace, + startSessionSwitchTrace, +} from "@src/engines/SessionCore/performance/sessionSwitchPerformance"; + import { type PanelState, type WorkStationLayoutState, @@ -76,11 +81,25 @@ function closePresentedTabs( export const focusTabAtom = atom(null, (get, set, request: TabFocusRequest) => { const layout = get(workstationLayoutAtom); if (!layout) return; - if (!layout.mainPane.tabs.some((tab) => tab.id === request.tabId)) return; + const targetTab = layout.mainPane.tabs.find( + (tab) => tab.id === request.tabId + ); + if (!targetTab) return; + const targetSessionId = + targetTab.type === "chat-session" && + layout.mainPane.activeTabId !== request.tabId + ? String(targetTab.data.sessionId ?? "") + : ""; + if (targetSessionId) { + startSessionSwitchTrace(targetSessionId, "workstation-tab"); + } set( workstationLayoutAtom, setMainPane(layout, switchTabMutation(layout.mainPane, request.tabId)) ); + if (targetSessionId) { + markSessionSwitchTrace(targetSessionId, "workstation-focus-persisted"); + } }); focusTabAtom.debugLabel = "focusTabAtom";