Merge pull request #2366 from pikasTech/fix/2356-response-json-storm

fix: gate Workbench heavy REST hydration
This commit is contained in:
Lyon
2026-07-03 06:25:08 +08:00
committed by GitHub
4 changed files with 139 additions and 4 deletions
@@ -141,6 +141,7 @@ test("workbench active terminal paths seal final response from turn authority",
const completeBlock = source.slice(source.indexOf("function completeTrace"), source.indexOf("async function hydrateTerminalMessageDiagnostics"));
const realtimeSessionDetailBlock = source.slice(source.indexOf("async function refreshRealtimeSessionFromRest"), source.indexOf("function completeTrace"));
const sessionDetailReadBlock = source.slice(source.indexOf("function fetchSessionDetailPage"), source.indexOf("function sessionMessageProjectionWindowLimit"));
const traceHydrationBlock = source.slice(source.indexOf("async function hydrateTraceEventsForMessagePages"), source.indexOf("async function fetchTraceHydrationPage"));
const loadBlock = source.slice(source.indexOf("async function loadWorkbenchSession"), source.indexOf("async function sealRestoredActiveTurnMessages"));
const restoreSealBlock = source.slice(source.indexOf("async function sealRestoredActiveTurnMessages"), source.indexOf("function reattachRestoredActiveTrace"));
@@ -160,9 +161,15 @@ test("workbench active terminal paths seal final response from turn authority",
assert.match(completeBlock, /traceProjectionIsTerminalSealed\(traceId, serverState\.value\.messagesBySessionId\[ownerSessionId\] \?\? \[\]\)[\s\S]*scheduleSessionListRefresh/u);
assert.match(realtimeSessionDetailBlock, /traceIdFromRealtimeRefreshReason\(reason\)/u);
assert.match(realtimeSessionDetailBlock, /traceTerminalBodyIsVisible\(traceId, id\)[\s\S]*terminal_low_priority_session_detail_skip[\s\S]*return/u);
assert.match(realtimeSessionDetailBlock, /hydrateTurnStatusAuthority\(activeMessagesBeforeDetail, \{ traceId, limit: 1, reason: "realtime-session-detail:turn-authority" \}\)/u);
assert.match(realtimeSessionDetailBlock, /sessionDetailAutoReadDecision\(\{ traceId,[\s\S]*workbenchSessionDetailMinRefreshMs \}\)/u);
assert.match(realtimeSessionDetailBlock, /session_detail_auto_read_skip/u);
assert.match(realtimeSessionDetailBlock, /traceTerminalBodyIsVisible\(traceId, id\)[\s\S]*terminal_low_priority_session_detail_apply_skip[\s\S]*return/u);
assert.match(realtimeSessionDetailBlock, /fetchSessionDetailPage\(id, \{ reason: `realtime-session-detail:\$\{reason\}` \}\)/u);
assert.doesNotMatch(realtimeSessionDetailBlock, /loadWorkbenchSession|applySelectedSessionDetail/u);
assert.match(traceHydrationBlock, /traceAuthorityById\.value\[traceId\] \?\? message\.runnerTrace/u);
assert.match(traceHydrationBlock, /traceEventsHydrationReadDecision\(traceId, afterProjectedSeq, message, options\)/u);
assert.match(traceHydrationBlock, /trace_events_auto_read_skip/u);
assert.match(sessionDetailReadBlock, /workbenchSessionDetailReadKey\(\{ sessionId, force: options\.force \}\)/u);
assert.match(sessionDetailReadBlock, /fetchSession\(sessionId, \{ includeMessages: false,/u);
assert.match(loadBlock, /const messageLimit = sessionMessageProjectionWindowLimit\(\);/u);
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { test } from "bun:test";
import type { ChatMessage } from "@/types";
import { projectionMergeCommitSummary, workbenchSessionDetailReadKey, workbenchSessionMessagesReadKey } from "./workbench-session-messages-read-budget";
import { projectionMergeCommitSummary, sessionDetailAutoReadDecision, traceEventsAutoReadDecision, workbenchSessionDetailReadKey, workbenchSessionMessagesReadKey, workbenchTraceEventsReadKey } from "./workbench-session-messages-read-budget";
import { selectActiveTurnStatusRefreshTraceIds } from "./workbench-session";
function message(id: string): ChatMessage {
@@ -27,6 +27,21 @@ test("session detail singleflight key separates force freshness class", () => {
assert.equal(fresh, workbenchSessionDetailReadKey({ sessionId: "ses_key" }));
});
test("trace events auto read skips initial and completed equivalent ranges", () => {
const key = workbenchTraceEventsReadKey({ traceId: "trc_range", afterProjectedSeq: 12, limit: 50 });
assert.equal(traceEventsAutoReadDecision({ traceId: "trc_range", afterProjectedSeq: 0, limit: 50 }).read, false);
assert.equal(traceEventsAutoReadDecision({ traceId: "trc_range", afterProjectedSeq: 12, limit: 50, cachedRange: { traceId: "trc_range", afterProjectedSeq: 12, limit: 50, nextProjectedSeq: 12, hasMore: false } }).read, false);
assert.equal(traceEventsAutoReadDecision({ traceId: "trc_range", afterProjectedSeq: 12, limit: 50, cachedRange: { traceId: "trc_range", afterProjectedSeq: 12, limit: 50, nextProjectedSeq: 40, hasMore: true } }).read, true);
assert.equal(key, workbenchTraceEventsReadKey({ traceId: "trc_range", afterProjectedSeq: 12, limit: 50 }));
});
test("session detail auto read defers trace scoped realtime refresh to turn authority", () => {
assert.deepEqual(sessionDetailAutoReadDecision({ traceId: "trc_turn", nowMs: 10_000, minIntervalMs: 5_000 }), { read: false, reason: "turn-authority-first" });
assert.deepEqual(sessionDetailAutoReadDecision({ nowMs: 10_000, lastReadAtMs: 9_000, minIntervalMs: 5_000 }), { read: false, reason: "min-interval" });
assert.deepEqual(sessionDetailAutoReadDecision({ nowMs: 10_000, lastReadAtMs: 1_000, minIntervalMs: 5_000 }), { read: true, reason: "metadata-only" });
});
test("projection merge summary reports no write without blocking follow-up hydrate", () => {
const existing = [{ ...message("msg_same"), traceId: "trc_current", sessionId: "ses_same" }];
const summary = projectionMergeCommitSummary(existing, existing);
@@ -1,5 +1,5 @@
// SPEC: pikasTech/HWLAB#2356 Workbench bounded request storm follow-up.
// Responsibility: Pure budgeting helpers for session messages read de-duplication and projection merge writes.
// Responsibility: Pure budgeting helpers for Workbench read hydration de-duplication and projection merge writes.
import type { ChatMessage } from "@/types";
import { firstNonEmptyString } from "@/utils";
@@ -21,6 +21,37 @@ export interface ProjectionMergeCommitSummary {
changedCount: number;
}
export interface TraceEventsReadRangeInput {
traceId: string | null | undefined;
afterProjectedSeq: number | null | undefined;
limit: number | null | undefined;
}
export interface TraceEventsReadRangeRecord extends TraceEventsReadRangeInput {
nextProjectedSeq: number | null;
hasMore: boolean;
}
export interface TraceEventsAutoReadDecisionInput extends TraceEventsReadRangeInput {
force?: boolean | null;
terminalBodyVisible?: boolean | null;
cachedRange?: TraceEventsReadRangeRecord | null;
}
export interface SessionDetailAutoReadDecisionInput {
force?: boolean | null;
traceId?: string | null;
terminalBodyVisible?: boolean | null;
lastReadAtMs?: number | null;
nowMs: number;
minIntervalMs: number;
}
export interface ReadHydrationDecision {
read: boolean;
reason: string;
}
export function workbenchSessionDetailReadKey(input: SessionDetailReadKeyInput): string {
return composeWorkbenchScopedKey(
"workbench.session-detail.read",
@@ -39,6 +70,35 @@ export function workbenchSessionMessagesReadKey(input: SessionMessagesReadKeyInp
);
}
export function workbenchTraceEventsReadKey(input: TraceEventsReadRangeInput): string {
return composeWorkbenchScopedKey(
"workbench.trace-events.read",
firstNonEmptyString(input.traceId) ?? "unknown-trace",
`after:${readSeqPart(input.afterProjectedSeq)}`,
`limit:${sessionMessagesReadLimitPart(input.limit)}`
);
}
export function traceEventsAutoReadDecision(input: TraceEventsAutoReadDecisionInput): ReadHydrationDecision {
if (input.force === true) return { read: true, reason: "force" };
if (input.terminalBodyVisible === true) return { read: false, reason: "terminal-visible" };
const afterProjectedSeq = readSeqPart(input.afterProjectedSeq);
if (afterProjectedSeq <= 0) return { read: false, reason: "initial-range-deferred" };
const cached = input.cachedRange;
if (cached && readSeqPart(cached.afterProjectedSeq) === afterProjectedSeq && sessionMessagesReadLimitPart(cached.limit) === sessionMessagesReadLimitPart(input.limit) && cached.hasMore !== true) return { read: false, reason: "equivalent-range-complete" };
return { read: true, reason: "delta-range" };
}
export function sessionDetailAutoReadDecision(input: SessionDetailAutoReadDecisionInput): ReadHydrationDecision {
if (input.force === true) return { read: true, reason: "force" };
if (input.terminalBodyVisible === true) return { read: false, reason: "terminal-visible" };
if (firstNonEmptyString(input.traceId)) return { read: false, reason: "turn-authority-first" };
const lastReadAtMs = Number(input.lastReadAtMs);
const minIntervalMs = Math.max(0, Math.trunc(Number(input.minIntervalMs)));
if (Number.isFinite(lastReadAtMs) && input.nowMs - lastReadAtMs < minIntervalMs) return { read: false, reason: "min-interval" };
return { read: true, reason: "metadata-only" };
}
export function projectionMergeCommitSummary(existing: ChatMessage[], merged: ChatMessage[]): ProjectionMergeCommitSummary {
let changedCount = Math.max(0, merged.length - existing.length);
const sharedLength = Math.min(existing.length, merged.length);
@@ -52,3 +112,8 @@ function sessionMessagesReadLimitPart(value: number | null | undefined): number
const number = Number(value);
return Number.isFinite(number) && number > 0 ? Math.trunc(number) : 1;
}
function readSeqPart(value: number | null | undefined): number {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? Math.trunc(number) : 0;
}
+50 -2
View File
@@ -70,7 +70,7 @@ import { planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery, type Workben
import { useWorkbenchColadaMutations } from "./workbench-colada-mutations";
import { useWorkbenchColadaQueries } from "./workbench-colada-queries";
import { useWorkbenchColadaReducer } from "./workbench-colada-reducer";
import { projectionMergeCommitSummary, workbenchSessionDetailReadKey, workbenchSessionMessagesReadKey } from "./workbench-session-messages-read-budget";
import { projectionMergeCommitSummary, sessionDetailAutoReadDecision, traceEventsAutoReadDecision, workbenchSessionDetailReadKey, workbenchSessionMessagesReadKey, workbenchTraceEventsReadKey, type TraceEventsReadRangeRecord } from "./workbench-session-messages-read-budget";
import { terminalSealResultWithoutTraceEvents, traceHydrationProjectedSeq, traceNextProjectedSeq } from "./workbench-trace-hydration";
const WORKBENCH_SESSION_PROJECTION_SIGNAL_CHANNEL = "hwlab.workbench.sessionProjection.v1";
@@ -113,6 +113,8 @@ export const useWorkbenchStore = defineStore("workbench", () => {
const traceHydrationSingleflight = createKeyedSingleflight<void>();
const sessionMessagesReadSingleflight = createKeyedSingleflight<ApiResult<WorkbenchMessagePageResponse>>();
const sessionDetailReadSingleflight = createKeyedSingleflight<ApiResult<WorkbenchSessionDetailResponse>>();
const traceEventsReadRanges = new Map<string, TraceEventsReadRangeRecord>();
const sessionDetailAutoReadAtMs = new Map<string, number>();
const realtimeTurnProjectionQueue = new Map<string, RealtimeTurnProjectionItem>();
let realtimeTurnProjectionScheduled = false;
const providerProfile = ref<ProviderProfile>(readString("hwlab.workbench.providerProfile.v1", "codex"));
@@ -888,9 +890,14 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function hydrateTraceEventsForMessagePages(message: ChatMessage, options: { force?: boolean } = {}): Promise<void> {
const traceId = message.traceId ?? message.runnerTrace?.traceId;
if (!traceId) return;
let afterProjectedSeq = traceHydrationProjectedSeq(message.runnerTrace);
let afterProjectedSeq = traceHydrationProjectedSeq(traceAuthorityById.value[traceId] ?? message.runnerTrace);
for (let page = 0; page < runtimePolicy.traceHydrationMaxPages; page += 1) {
if (traceTerminalBodyIsVisible(traceId, message.sessionId ?? message.runnerTrace?.sessionId)) return;
const decision = traceEventsHydrationReadDecision(traceId, afterProjectedSeq, message, options);
if (!decision.read) {
recordTraceEventsHydrationSkip(traceId, message, decision.reason, afterProjectedSeq);
return;
}
const result = await fetchTraceHydrationPage(traceId, afterProjectedSeq, { force: options.force });
if (!result.ok || !result.data) {
if (shouldSuppressTransientWorkbenchReadFailure(result)) return;
@@ -900,11 +907,40 @@ export const useWorkbenchStore = defineStore("workbench", () => {
}
applyTraceHydrationResult(traceId, result.data);
const nextProjectedSeq = traceNextProjectedSeq(result.data, afterProjectedSeq);
rememberTraceEventsHydrationRead(traceId, afterProjectedSeq, nextProjectedSeq, result.data);
if (result.data.hasMore !== true || nextProjectedSeq <= afterProjectedSeq) return;
afterProjectedSeq = nextProjectedSeq;
}
}
function traceEventsHydrationReadDecision(traceId: string, afterProjectedSeq: number, message: ChatMessage, options: { force?: boolean }): { read: boolean; reason: string } {
const limit = runtimePolicy.traceHydrationPageLimit;
const key = workbenchTraceEventsReadKey({ traceId, afterProjectedSeq, limit });
return traceEventsAutoReadDecision({
traceId,
afterProjectedSeq,
limit,
force: options.force,
terminalBodyVisible: traceTerminalBodyIsVisible(traceId, message.sessionId ?? message.runnerTrace?.sessionId),
cachedRange: traceEventsReadRanges.get(key) ?? null
});
}
function rememberTraceEventsHydrationRead(traceId: string, afterProjectedSeq: number, nextProjectedSeq: number, result: AgentChatResultResponse): void {
const limit = runtimePolicy.traceHydrationPageLimit;
traceEventsReadRanges.set(workbenchTraceEventsReadKey({ traceId, afterProjectedSeq, limit }), { traceId, afterProjectedSeq, limit, nextProjectedSeq, hasMore: result.hasMore === true });
}
function recordTraceEventsHydrationSkip(traceId: string, message: ChatMessage, reason: string, afterProjectedSeq: number): void {
recordWorkbenchRuntimeDiagnostic({
module: "workbench-trace-events-read",
sessionId: message.sessionId ?? message.runnerTrace?.sessionId ?? null,
traceId,
outcome: "ok",
diagnostic: { code: "trace_events_auto_read_skip", reason, source: "trace-hydration", afterProjectedSeq, limit: runtimePolicy.traceHydrationPageLimit, valuesRedacted: true }
});
}
async function fetchTraceHydrationPage(traceId: string, afterProjectedSeq: number, options: { force?: boolean } = {}): Promise<ApiResult<AgentChatResultResponse>> {
return fetchWorkbenchTraceEvents(traceId, afterProjectedSeq, shouldUseActivityTimeoutForTrace(traceId), { force: options.force });
}
@@ -1495,7 +1531,19 @@ export const useWorkbenchStore = defineStore("workbench", () => {
recordWorkbenchRuntimeDiagnostic({ module: "workbench-terminal-priority", sessionId: id, traceId, outcome: "ok", diagnostic: { code: "terminal_low_priority_session_detail_skip", reason, source: "realtime-session-detail", valuesRedacted: true } });
return;
}
const activeMessagesBeforeDetail = serverState.value.messagesBySessionId[id] ?? messages.value;
await hydrateTurnStatusAuthority(activeMessagesBeforeDetail, { traceId, limit: 1, reason: "realtime-session-detail:turn-authority" });
if (traceTerminalBodyIsVisible(traceId, id)) {
recordWorkbenchRuntimeDiagnostic({ module: "workbench-terminal-priority", sessionId: id, traceId, outcome: "ok", diagnostic: { code: "terminal_low_priority_session_detail_turn_skip", reason, source: "realtime-session-detail", valuesRedacted: true } });
return;
}
const detailDecision = sessionDetailAutoReadDecision({ traceId, terminalBodyVisible: false, lastReadAtMs: sessionDetailAutoReadAtMs.get(id) ?? null, nowMs: Date.now(), minIntervalMs: runtimePolicy.workbenchSessionDetailMinRefreshMs });
if (!detailDecision.read) {
recordWorkbenchRuntimeDiagnostic({ module: "workbench-session-detail-read", sessionId: id, traceId, outcome: "ok", diagnostic: { code: "session_detail_auto_read_skip", reason: detailDecision.reason, source: "realtime-session-detail", valuesRedacted: true } });
return;
}
recordActivity(reason);
sessionDetailAutoReadAtMs.set(id, Date.now());
const detail = await fetchSessionDetailPage(id, { reason: `realtime-session-detail:${reason}` });
if (!detail.ok || activeSessionId.value !== id) return;
const detailSession = sessionFromWorkbenchSession(detail.data?.session, { includeMessages: false });