From ef32a1e87e01fca1d443e041e2d3f620f8f630d0 Mon Sep 17 00:00:00 2001 From: lyon Date: Mon, 22 Jun 2026 05:19:34 +0800 Subject: [PATCH] fix(workbench): seal terminal turn timing and diagnostics --- cmd/hwlab-edge-proxy/main.mjs | 4 +- .../workbench/ConversationPanel.vue | 20 ++++++++- .../src/composables/useTraceSubscription.ts | 19 ++++++-- web/hwlab-cloud-web/src/stores/workbench.ts | 45 ++++++++++++++----- 4 files changed, 71 insertions(+), 17 deletions(-) diff --git a/cmd/hwlab-edge-proxy/main.mjs b/cmd/hwlab-edge-proxy/main.mjs index 0b790488..a8f944dc 100644 --- a/cmd/hwlab-edge-proxy/main.mjs +++ b/cmd/hwlab-edge-proxy/main.mjs @@ -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: { diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index 369d63b4..eaeeabd6 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -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"; } diff --git a/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts b/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts index 1d316771..49c087e2 100644 --- a/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts +++ b/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts @@ -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; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index d01026c7..8a870494 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -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 { function messageTimingPatchForMerge(message: ChatMessage, value: unknown): Partial { 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) {