fix(workbench): seal terminal turn timing and diagnostics

This commit is contained in:
lyon
2026-06-22 05:19:34 +08:00
parent 643732bbcf
commit ef32a1e87e
4 changed files with 71 additions and 17 deletions
+2 -2
View File
@@ -235,8 +235,8 @@ function isInternalRoute(pathname) {
function proxyFailurePayload({ timedOut, timeoutMs, upstreamUrl, edgeTraceId }) {
const code = timedOut ? "proxy_timeout" : "upstream_unavailable";
const userMessage = timedOut
? `Code Agent 代理等待上游超过 ${timeoutMs}ms;输入已保留,可稍后重试。`
: "Code Agent 代理暂时无法连接上游;输入已保留,可稍后重试。";
? `Workbench 后端请求超过 ${timeoutMs}ms 未返回;请求已保留,可稍后重试。`
: "Workbench 后端暂时不可用;请求已保留,可稍后重试。";
return {
status: "failed",
error: {
@@ -77,6 +77,15 @@ function isTerminalAgentMessage(message: ChatMessage): boolean {
return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(message.status ?? "").trim().toLowerCase().replace(/_/gu, "-"));
}
function isCompletedAgentMessage(message: ChatMessage): boolean {
if (message.role !== "agent") return false;
return [message.status, message.runnerTrace?.status].some((value) => String(value ?? "").trim().toLowerCase().replace(/_/gu, "-") === "completed");
}
function hasSealedCompletedText(message: ChatMessage): boolean {
return isCompletedAgentMessage(message) && Boolean(visibleMessageText(message));
}
function visibleMessageText(message: ChatMessage): string {
const text = String(message.text ?? "").trim();
if (message.role === "agent" && !isTerminalAgentMessage(message)) return "";
@@ -102,10 +111,12 @@ function messageDiagnosticText(message: ChatMessage): string | null {
}
function messageHasDiagnostic(message: ChatMessage): boolean {
if (hasSealedCompletedText(message)) return false;
return Boolean(messageDiagnosticText(message) || messageApiError(message) || messageErrorDiagnostic(message));
}
function projectionDiagnosticText(message: ChatMessage): string | null {
if (hasSealedCompletedText(message)) return null;
const projection = messageProjection(message);
const health = projection?.projectionHealth ?? message.projectionHealth ?? message.runnerTrace?.projectionHealth;
if (!projection || health === "caught-up") return null;
@@ -117,6 +128,7 @@ function messageProjection(message: ChatMessage): ChatMessage["projection"] {
}
function messageApiError(message: ChatMessage): ApiError | null {
if (hasSealedCompletedText(message)) return null;
const projection = messageProjection(message);
const blocker = recordValue(projection?.blocker ?? message.blocker ?? message.runnerTrace?.blocker);
const error = recordValue(message.error);
@@ -143,6 +155,7 @@ function messageApiError(message: ChatMessage): ApiError | null {
}
function messageErrorDiagnostic(message: ChatMessage): ErrorDiagnostic | null {
if (hasSealedCompletedText(message)) return null;
const projection = messageProjection(message);
const blocker = recordValue(projection?.blocker ?? message.blocker ?? message.runnerTrace?.blocker);
const error = recordValue(message.error);
@@ -244,7 +257,7 @@ function messageDurationMeta(message: ChatMessage): { text: string; label: strin
const timing = messageTimingForDisplay(message);
const rawDurationMs = isRunningMessage(message) ? durationSince(timing?.startedAt) : finiteDurationMs(timing?.durationMs);
if (rawDurationMs === null) return null;
const durationMs = monotonicDisplayDurationMs(message, rawDurationMs);
const durationMs = isRunningMessage(message) ? monotonicDisplayDurationMs(message, rawDurationMs) : sealedDisplayDurationMs(message, rawDurationMs);
const value = formatDuration(durationMs);
return { text: `耗时 ${value}`, label: `总耗时:${value}` };
}
@@ -257,6 +270,11 @@ function monotonicDisplayDurationMs(message: ChatMessage, durationMs: number): n
return next;
}
function sealedDisplayDurationMs(message: ChatMessage, durationMs: number): number {
displayedDurationFloorMs.delete(messageDurationFloorKey(message));
return durationMs;
}
function messageDurationFloorKey(message: ChatMessage): string {
return firstNonEmptyString(message.id, message.runnerTrace?.traceId, message.traceId) ?? "unknown-agent-message";
}
@@ -118,7 +118,7 @@ export function mergeRunnerTrace(previous: ChatMessage["runnerTrace"], next: Non
: keepPreviousEvents && previousAuthoritative && next.eventsCompacted === true ? previousEvents
: keepPreviousEvents ? mergeTraceEvents(previousEvents, nextEvents) : nextEvents.length > previousEvents.length ? mergeTraceEvents(previousEvents, nextEvents) : nextEvents;
const eventCount = keepPreviousEvents ? previous.eventCount ?? events.length : next.eventCount ?? previous.eventCount ?? events.length;
const timing = mergeTraceTimingProjection(previous, next, { preserveVisibleTerminalDuration: traceTimingWasRunning(previous) && traceTimingIsTerminal(next) });
const timing = mergeTraceTimingProjection(previous, next);
return {
...previous,
...next,
@@ -133,7 +133,7 @@ export function mergeRunnerTrace(previous: ChatMessage["runnerTrace"], next: Non
};
}
function mergeTraceTimingProjection(previous: unknown, next: unknown, options: { preserveVisibleTerminalDuration?: boolean } = {}): TraceSnapshot["timing"] | null {
function mergeTraceTimingProjection(previous: unknown, next: unknown): TraceSnapshot["timing"] | null {
const previousTiming = traceTimingCandidate(previous);
const nextTiming = traceTimingCandidate(next);
if (!previousTiming && !nextTiming) return null;
@@ -143,8 +143,11 @@ function mergeTraceTimingProjection(previous: unknown, next: unknown, options: {
const lastEventAt = latestTraceTimestamp(previousTiming.lastEventAt, nextTiming.lastEventAt);
const finishedAt = latestTraceTimestamp(previousTiming.finishedAt, nextTiming.finishedAt);
const elapsedDurationMs = traceElapsedMs(startedAt, finishedAt ?? lastEventAt);
const visibleTerminalDurationMs = options.preserveVisibleTerminalDuration === true ? traceElapsedMs(startedAt, new Date().toISOString()) : null;
const durationMs = maxTraceDuration(previousTiming.durationMs, nextTiming.durationMs, elapsedDurationMs, visibleTerminalDurationMs);
const terminalTiming = traceTimingIsTerminal(next) || finishedAt !== null;
const previousTerminalDurationMs = previousTiming.finishedAt ? previousTiming.durationMs : null;
const durationMs = terminalTiming
? firstTraceDuration(nextTiming.durationMs, elapsedDurationMs, previousTerminalDurationMs)
: maxTraceDuration(previousTiming.durationMs, nextTiming.durationMs, elapsedDurationMs);
return {
...previousTiming,
...nextTiming,
@@ -222,6 +225,14 @@ function maxTraceDuration(...values: unknown[]): number | null {
return max;
}
function firstTraceDuration(...values: unknown[]): number | null {
for (const value of values) {
const duration = finiteTraceDuration(value);
if (duration !== null) return duration;
}
return null;
}
function finiteTraceDuration(value: unknown): number | null {
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
+35 -10
View File
@@ -1195,7 +1195,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
const message = [...messages.value].reverse().find((item) => shouldApplyTraceToMessage(item, traceId, activeSessionId.value)) ?? null;
const sessionId = normalizeWorkbenchSessionId(firstNonEmptyString(message?.sessionId, message?.runnerTrace?.sessionId, activeSessionId.value));
const existing = turnStatusAuthority.value[traceId];
if (existing?.terminal === true && normalizedStatusText(existing.status) === "completed") return;
if (existing?.terminal === true || messageHasSealedTerminalResult(message)) return;
const status = existing?.status ?? normalizedStatusText(message?.status) ?? null;
const running = existing?.running === true || (!isTerminalMessageStatus(message?.status) && isTraceActiveStatus(message?.status));
const now = new Date().toISOString();
@@ -1204,12 +1204,19 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project
const projectionError = agentErrorFromProjection(projection);
updateActiveMessages((source) => source.map((item) => {
if (!shouldApplyTraceToMessage(item, traceId, sessionId)) return item;
if (messageHasSealedTerminalResult(item)) return item;
const runnerTrace = mergeRunnerTrace(item.runnerTrace, { ...(item.runnerTrace ?? {}), traceId, sessionId: sessionId ?? item.runnerTrace?.sessionId, projection, projectionStatus: projection.projectionStatus ?? null, projectionHealth: projection.projectionHealth ?? null, staleMs: projection.staleMs ?? null, blocker: projection.blocker ?? null, events: item.runnerTrace?.events ?? [], eventCount: item.runnerTrace?.eventCount ?? item.runnerTrace?.events?.length ?? 0, eventSource: item.runnerTrace?.eventSource ?? "projection-diagnostic", updatedAt: now });
rememberTraceAuthority(runnerTrace);
return { ...item, runnerTrace, error: projectionError ? { ...(item.error ?? {}), ...projectionError } : item.error ?? null, projection, projectionStatus: projection.projectionStatus ?? null, projectionHealth: projection.projectionHealth ?? null, blocker: projection.blocker ?? null, updatedAt: now };
}));
}
function messageHasSealedTerminalResult(message: ChatMessage | null): boolean {
if (!message) return false;
if (isTerminalMessageStatus(message.status) || isTerminalMessageStatus(message.runnerTrace?.status)) return true;
return message.runnerTrace ? traceSnapshotHasTerminalEvidence(message.runnerTrace) : false;
}
function failTrace(traceId: string, message: string): void {
markMessage(traceId, { status: "failed", text: message });
chatPending.value = false;
@@ -1550,14 +1557,14 @@ function normalizeMessageRunnerTrace(message: ChatMessage): ChatMessage["runnerT
function normalizeTimingProjection(value: unknown): WorkbenchTurnTimingProjection | null {
const record = recordValue(value);
const source = recordValue(record?.timing);
if (!source) return null;
const startedAt = firstNonEmptyString(source.startedAt);
const lastEventAt = firstNonEmptyString(source.lastEventAt);
const finishedAt = firstNonEmptyString(source.finishedAt);
const durationMs = firstFiniteNumber(source.durationMs);
const observedAt = firstNonEmptyString(source.observedAt);
const lastEventAgeMs = firstFiniteNumber(source.lastEventAgeMs);
if (!record) return null;
const source = recordValue(record.timing) ?? {};
const startedAt = firstNonEmptyString(source.startedAt, record.startedAt);
const lastEventAt = firstNonEmptyString(source.lastEventAt, record.lastEventAt);
const finishedAt = firstNonEmptyString(source.finishedAt, record.finishedAt);
const durationMs = firstFiniteNumber(source.durationMs, record.durationMs);
const observedAt = firstNonEmptyString(source.observedAt, record.observedAt);
const lastEventAgeMs = firstFiniteNumber(source.lastEventAgeMs, record.lastEventAgeMs);
if (!startedAt && !lastEventAt && !finishedAt && durationMs == null && lastEventAgeMs == null) return null;
return { ...source, startedAt: startedAt ?? null, lastEventAt: lastEventAt ?? null, finishedAt: finishedAt ?? null, durationMs: durationMs ?? null, observedAt: observedAt ?? null, lastEventAgeMs: lastEventAgeMs ?? null, valuesRedacted: source.valuesRedacted !== false } as WorkbenchTurnTimingProjection;
}
@@ -1571,11 +1578,29 @@ function messageTimingPatch(value: unknown): Partial<ChatMessage> {
function messageTimingPatchForMerge(message: ChatMessage, value: unknown): Partial<ChatMessage> {
const patch = messageTimingPatch(value);
if (!patch.timing) return patch;
const durationMs = maxFiniteNumber(patch.timing.durationMs, message.durationMs, message.timing?.durationMs, message.runnerTrace?.durationMs, message.runnerTrace?.timing?.durationMs);
const terminal = timingPatchIsTerminal(value, patch.timing);
const durationMs = terminal
? firstFiniteNumber(patch.timing.durationMs, elapsedTimingDurationMs(patch.timing))
: maxFiniteNumber(patch.timing.durationMs, message.durationMs, message.timing?.durationMs, message.runnerTrace?.durationMs, message.runnerTrace?.timing?.durationMs);
const timing = { ...patch.timing, durationMs } as WorkbenchTurnTimingProjection;
return { timing, startedAt: timing.startedAt ?? null, lastEventAt: timing.lastEventAt ?? null, finishedAt: timing.finishedAt ?? null, durationMs };
}
function timingPatchIsTerminal(value: unknown, timing: WorkbenchTurnTimingProjection): boolean {
const record = recordValue(value);
const trace = recordValue(record?.runnerTrace);
const status = normalizedStatusText(record?.status ?? record?.traceStatus ?? trace?.status ?? trace?.traceStatus);
return Boolean(timing.finishedAt || isTerminalMessageStatus(status));
}
function elapsedTimingDurationMs(timing: WorkbenchTurnTimingProjection): number | null {
const start = timing.startedAt ? Date.parse(timing.startedAt) : NaN;
const endedAt = timing.finishedAt ?? timing.lastEventAt;
const end = endedAt ? Date.parse(endedAt) : NaN;
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null;
return Math.trunc(end - start);
}
function maxFiniteNumber(...values: unknown[]): number | null {
let max: number | null = null;
for (const value of values) {