fix: coalesce Workbench realtime session refresh (#1902)

This commit is contained in:
Lyon
2026-06-22 16:27:15 +08:00
committed by GitHub
parent 153727d111
commit 98d3b5502e
+36 -10
View File
@@ -23,6 +23,8 @@ const ACTIVE_TURN_GAP_INITIAL_DELAY_MS = 1_000;
const ACTIVE_TURN_GAP_MAX_DELAY_MS = 5_000; const ACTIVE_TURN_GAP_MAX_DELAY_MS = 5_000;
const SESSION_LIST_PAGE_LIMIT = 20; const SESSION_LIST_PAGE_LIMIT = 20;
const WORKBENCH_READ_HYDRATION_CONCURRENCY = 3; const WORKBENCH_READ_HYDRATION_CONCURRENCY = 3;
const SESSION_LIST_REALTIME_REFRESH_DELAY_MS = 1_200;
const SESSION_LIST_TERMINAL_REFRESH_DELAY_MS = 400;
interface HydrateOptions { interface HydrateOptions {
sessionId?: string | null; sessionId?: string | null;
@@ -70,6 +72,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
let realtimeGapTimer: number | null = null; let realtimeGapTimer: number | null = null;
let activeTurnGapTimer: number | null = null; let activeTurnGapTimer: number | null = null;
const sessionListRefreshInFlight = new Map<string, Promise<void>>(); const sessionListRefreshInFlight = new Map<string, Promise<void>>();
const sessionListRefreshTimers = new Map<string, number>();
let workbenchReadHydrationActive = 0; let workbenchReadHydrationActive = 0;
const workbenchReadHydrationQueue: Array<() => void> = []; const workbenchReadHydrationQueue: Array<() => void> = [];
@@ -267,7 +270,7 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function refreshSessions(includeSessionId: string | null = activeSessionId.value): Promise<void> { async function refreshSessions(includeSessionId: string | null = activeSessionId.value): Promise<void> {
const requestIncludeSessionId = firstNonEmptyString(includeSessionId); const requestIncludeSessionId = firstNonEmptyString(includeSessionId);
const requestLimit = currentSessionListLimit(); const requestLimit = currentSessionListLimit();
const requestKey = `${requestIncludeSessionId ?? ""}|${requestLimit}`; const requestKey = sessionListRefreshKey(requestIncludeSessionId, requestLimit);
const existing = sessionListRefreshInFlight.get(requestKey); const existing = sessionListRefreshInFlight.get(requestKey);
if (existing) return existing; if (existing) return existing;
const refresh = refreshSessionsNow(requestIncludeSessionId, requestLimit).finally(() => { const refresh = refreshSessionsNow(requestIncludeSessionId, requestLimit).finally(() => {
@@ -294,6 +297,27 @@ export const useWorkbenchStore = defineStore("workbench", () => {
if (sessions.value.length === 0) error.value = response.error ?? "session list unavailable"; if (sessions.value.length === 0) error.value = response.error ?? "session list unavailable";
} }
function scheduleSessionListRefresh(includeSessionId: string | null | undefined = activeSessionId.value, delayMs = SESSION_LIST_REALTIME_REFRESH_DELAY_MS): void {
const requestIncludeSessionId = firstNonEmptyString(includeSessionId);
const requestLimit = currentSessionListLimit();
const requestKey = sessionListRefreshKey(requestIncludeSessionId, requestLimit);
if (typeof window === "undefined") {
void refreshSessions(requestIncludeSessionId);
return;
}
const existing = sessionListRefreshTimers.get(requestKey);
if (existing) window.clearTimeout(existing);
const timer = window.setTimeout(() => {
sessionListRefreshTimers.delete(requestKey);
void refreshSessions(requestIncludeSessionId);
}, Math.max(0, Math.trunc(delayMs)));
sessionListRefreshTimers.set(requestKey, timer);
}
function sessionListRefreshKey(includeSessionId: string | null | undefined, limit: number): string {
return `${firstNonEmptyString(includeSessionId) ?? ""}|${limit}`;
}
async function loadMoreSessions(): Promise<void> { async function loadMoreSessions(): Promise<void> {
if (sessionListLoadingMore.value || !sessionListHasMore.value) return; if (sessionListLoadingMore.value || !sessionListHasMore.value) return;
const cursor = sessionListNextCursor.value; const cursor = sessionListNextCursor.value;
@@ -624,7 +648,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
status: response.data.status ?? "running" status: response.data.status ?? "running"
}; };
applyTurnStatusSnapshot(canonicalTraceId, response.data); applyTurnStatusSnapshot(canonicalTraceId, response.data);
void refreshSessions(sessionId); scheduleSessionListRefresh(sessionId, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS);
if ((response.data as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(response.data.status)) { if ((response.data as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(response.data.status)) {
completeTrace(canonicalTraceId, response.data as AgentChatResultResponse); completeTrace(canonicalTraceId, response.data as AgentChatResultResponse);
return true; return true;
@@ -653,7 +677,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
} }
if (message.status === "running") chatPending.value = false; if (message.status === "running") chatPending.value = false;
currentRequest.value = null; currentRequest.value = null;
void refreshSessions(message.sessionId ?? selectedSessionId.value); scheduleSessionListRefresh(message.sessionId ?? selectedSessionId.value, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS);
} }
async function cancelRunningTrace(): Promise<void> { async function cancelRunningTrace(): Promise<void> {
@@ -1038,8 +1062,8 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
async function hydrateRealtimeGap(reason: string): Promise<void> { async function hydrateRealtimeGap(reason: string): Promise<void> {
recordActivity(`realtime-gap:${reason}`); recordActivity(`realtime-gap:${reason}`);
scheduleSessionListRefresh(activeSessionId.value, SESSION_LIST_REALTIME_REFRESH_DELAY_MS);
await Promise.all([ await Promise.all([
refreshSessions(),
hydrateTurnStatusAuthority(messages.value), hydrateTurnStatusAuthority(messages.value),
hydrateTraceEvents(messages.value) hydrateTraceEvents(messages.value)
]); ]);
@@ -1114,7 +1138,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
return { ...message, ...messageTimingPatchForMerge(message, trace), runnerTrace, error: clearCompletedDiagnostics ? null : error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, updatedAt: new Date().toISOString() }; return { ...message, ...messageTimingPatchForMerge(message, trace), runnerTrace, error: clearCompletedDiagnostics ? null : error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, updatedAt: new Date().toISOString() };
})); }));
markWorkbenchTraceProjected(traceId); markWorkbenchTraceProjected(traceId);
void refreshSessions(ownerSessionId); scheduleSessionListRefresh(ownerSessionId, SESSION_LIST_REALTIME_REFRESH_DELAY_MS);
} }
function completeTrace(traceId: string, result: AgentChatResultResponse): void { function completeTrace(traceId: string, result: AgentChatResultResponse): void {
@@ -1148,7 +1172,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
void clearActiveTrace(traceId, "trace-terminal"); void clearActiveTrace(traceId, "trace-terminal");
restartRealtime("trace-terminal"); restartRealtime("trace-terminal");
} }
void refreshSessions(ownerSessionId); scheduleSessionListRefresh(ownerSessionId, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS);
} }
async function hydrateTerminalMessageDiagnostics(): Promise<void> { async function hydrateTerminalMessageDiagnostics(): Promise<void> {
@@ -1183,7 +1207,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
return { ...message, ...messageTimingPatchForMerge(message, result), title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; return { ...message, ...messageTimingPatchForMerge(message, result), title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() };
})); }));
void hydrateTraceEvents(serverState.value.messagesBySessionId[ownerSessionId] ?? []); void hydrateTraceEvents(serverState.value.messagesBySessionId[ownerSessionId] ?? []);
void refreshSessions(ownerSessionId); scheduleSessionListRefresh(ownerSessionId, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS);
} }
function applyRealtimeProjectionError(event: WorkbenchRealtimeEvent): void { function applyRealtimeProjectionError(event: WorkbenchRealtimeEvent): void {
@@ -1225,7 +1249,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
chatPending.value = false; chatPending.value = false;
currentRequest.value = null; currentRequest.value = null;
void clearActiveTrace(traceId, "trace-infrastructure-error"); void clearActiveTrace(traceId, "trace-infrastructure-error");
void refreshSessions(selectedSessionId.value); scheduleSessionListRefresh(selectedSessionId.value, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS);
} }
function projectOptimisticRunningTurn(input: { sessionId: string; threadId: string | null; traceId: string; userText: string }): void { function projectOptimisticRunningTurn(input: { sessionId: string; threadId: string | null; traceId: string; userText: string }): void {
@@ -1365,12 +1389,14 @@ function sessionFromWorkbenchSession(value: unknown): WorkbenchSessionRecord | n
async function loadWorkbenchSession(sessionId: string, seed: WorkbenchSessionRecord | null = null): Promise<WorkbenchSessionRecord | null> { async function loadWorkbenchSession(sessionId: string, seed: WorkbenchSessionRecord | null = null): Promise<WorkbenchSessionRecord | null> {
const requestId = normalizeWorkbenchSessionRouteId(sessionId); const requestId = normalizeWorkbenchSessionRouteId(sessionId);
if (!requestId) return null; if (!requestId) return null;
const normalizedRequestId = normalizeWorkbenchSessionId(requestId);
const eagerMessages = normalizedRequestId ? api.workbench.sessionMessages(normalizedRequestId, { limit: 100 }) : null;
const detail = await api.workbench.session(requestId); const detail = await api.workbench.session(requestId);
if (!detail.ok) return null; if (!detail.ok) return null;
const detailSession = sessionFromWorkbenchSession(detail.data?.session); const detailSession = sessionFromWorkbenchSession(detail.data?.session);
const id = detailSession?.sessionId ?? normalizeWorkbenchSessionId(requestId) ?? seed?.sessionId; const id = detailSession?.sessionId ?? normalizedRequestId ?? seed?.sessionId;
if (!id) return null; if (!id) return null;
const messages = await api.workbench.sessionMessages(id, { limit: 100 }); const messages = eagerMessages && id === normalizedRequestId ? await eagerMessages : await api.workbench.sessionMessages(id, { limit: 100 });
const base = detailSession ?? seed; const base = detailSession ?? seed;
if (!base) return null; if (!base) return null;
const page = messages.ok ? messages.data : null; const page = messages.ok ? messages.data : null;