From e265c863c55814782105dd5b3297f11ced341cbb Mon Sep 17 00:00:00 2001 From: lyon Date: Wed, 17 Jun 2026 09:13:03 +0800 Subject: [PATCH] fix(v03): stabilize workbench trace loading --- internal/cloud/code-agent-agentrun-adapter.ts | 76 ++++++++++++++++++- internal/cloud/server-code-agent-http.ts | 41 ++++++++-- .../workbench/ConversationPanel.vue | 6 +- .../src/composables/useTraceSubscription.ts | 3 +- web/hwlab-cloud-web/src/stores/workbench.ts | 27 ++++++- web/hwlab-cloud-web/src/styles/workbench.css | 9 +++ 6 files changed, 149 insertions(+), 13 deletions(-) diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 3d5e4c22..8f6a48f7 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -433,7 +433,7 @@ function isAgentRunCommandAlreadyClaimed(error) { return statusCode === 409 && /command\s+[^\s]+\s+is not pending:/u.test(message); } -export async function syncAgentRunChatResult({ traceId, currentResult = null, options = {}, traceStore = defaultCodeAgentTraceStore, appendResultEvent = true, refreshEvents = true }) { +export async function syncAgentRunChatResult({ traceId, currentResult = null, options = {}, traceStore = defaultCodeAgentTraceStore, appendResultEvent = true, refreshEvents = true, forceResultSync = false }) { const initial = currentResult ?? await loadPersistedAgentRunResult(traceId, options); const mapped = initial ? await resolveAgentRunTraceCommandMapping({ traceId, mapped: initial, options }) : initial; if (!mapped?.agentRun?.runId || !mapped?.agentRun?.commandId) return { result: currentResult, runnerTrace: traceStore.snapshot(traceId), found: Boolean(currentResult) }; @@ -443,6 +443,21 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op const timeoutMs = parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, 20_000); const eventsResponse = refreshEvents ? await fetchAgentRunEventsForTrace({ fetchImpl, managerUrl, timeoutMs, env, mapping: { ...mapped.agentRun, traceSummary: mapped.traceSummary } }) : null; if (eventsResponse) appendAgentRunEventsToTrace(traceStore, traceId, eventsResponse.events, mapped.agentRun); + if (!forceResultSync && !agentRunCommandResultSyncRequired(mapped, eventsResponse)) { + const nextMapping = withAgentRunRunnerJobCount({ + ...mapped.agentRun, + lastSeq: eventsResponse ? agentRunTraceCursorSeq(eventsResponse, mapped.agentRun.lastSeq) : mapped.agentRun.lastSeq, + status: mapped.agentRun.status ?? "running", + runStatus: mapped.agentRun.runStatus ?? null, + commandState: mapped.agentRun.commandState ?? null, + terminalStatus: null, + updatedAt: nowIso(options.now), + valuesPrinted: false + }); + const payload = decorateAgentRunRunningResult({ base: { ...mapped, status: "running", agentRun: nextMapping, updatedAt: nowIso(options.now) }, mapping: nextMapping, traceStore, traceId }); + options.codeAgentChatResults?.set?.(traceId, payload); + return { result: payload, runnerTrace: payload.runnerTrace ?? traceStore.snapshot(traceId), found: true }; + } const result = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(mapped.agentRun.runId)}/commands/${encodeURIComponent(mapped.agentRun.commandId)}/result`, { method: "GET", timeoutMs, @@ -465,6 +480,42 @@ export async function syncAgentRunChatResult({ traceId, currentResult = null, op return { result: payload, runnerTrace: payload.runnerTrace ?? traceStore.snapshot(traceId), found: true }; } +function agentRunCommandResultSyncRequired(mapped = {}, eventsResponse = null) { + const agentRun = mapped?.agentRun && typeof mapped.agentRun === "object" ? mapped.agentRun : {}; + if (isAgentRunTerminalStatus(mapped?.status)) return true; + if (isAgentRunTerminalStatus(agentRun.terminalStatus ?? agentRun.commandState ?? agentRun.status)) return true; + return Boolean(agentRunTerminalStatusFromEvents(eventsResponse?.events)); +} + +function agentRunTerminalStatusFromEvents(events = []) { + if (!Array.isArray(events)) return null; + for (const event of [...events].reverse()) { + const payload = event?.payload && typeof event.payload === "object" ? event.payload : {}; + const candidates = [ + event?.type === "terminal_status" ? payload.terminalStatus : null, + payload.phase === "command-terminal" ? payload.terminalStatus : null, + payload.phase === "turn:completed" || payload.phase === "turn/steer:completed" ? "completed" : null, + payload.terminalStatus, + payload.status + ]; + for (const value of candidates) { + const status = normalizeAgentRunStatus(value); + if (isAgentRunTerminalStatus(status)) return status; + } + } + return null; +} + +function isAgentRunTerminalStatus(value) { + const status = normalizeAgentRunStatus(value); + return status === "timeout" || TERMINAL_RUN_STATUSES.has(status); +} + +function normalizeAgentRunStatus(value) { + const status = String(value ?? "").trim().toLowerCase().replace(/_/gu, "-"); + return status === "cancelled" ? "canceled" : status; +} + async function resolveAgentRunTraceCommandMapping({ traceId, mapped, options = {} }) { const safeId = safeTraceId(traceId); const agentRun = mapped?.agentRun && typeof mapped.agentRun === "object" ? mapped.agentRun : null; @@ -1940,7 +1991,7 @@ async function agentRunJson(fetchImpl, managerUrl, path, { method = "GET", body, return parsed?.ok === true && Object.hasOwn(parsed, "data") ? parsed.data : parsed; } catch (error) { if (error?.name === "AbortError") { - throw Object.assign(new Error(`AgentRun ${method} ${path} timed out after ${timeoutMs}ms`), { code: "agentrun_timeout", statusCode: 504 }); + throw Object.assign(new Error(`AgentRun ${method} ${path} timed out after ${timeoutMs}ms`), agentRunHttpErrorContext({ managerUrl, path, method, timeoutMs, code: "agentrun_timeout", statusCode: 504, category: "upstream-timeout" })); } throw error; } finally { @@ -1948,6 +1999,27 @@ async function agentRunJson(fetchImpl, managerUrl, path, { method = "GET", body, } } +function agentRunHttpErrorContext({ managerUrl, path, method, timeoutMs, code, statusCode, category }) { + return { + code, + statusCode, + layer: "agentrun", + category, + retryable: true, + method, + route: path, + path, + timeoutMs, + timeoutStage: "agentrun-http", + managerHost: safeUrlHost(managerUrl), + valuesPrinted: false + }; +} + +function safeUrlHost(value) { + try { return new URL(value).host; } catch { return null; } +} + function resolveAgentRunManagerUrl(env = process.env, override = null) { const raw = firstNonEmpty(override, env.AGENTRUN_MGR_URL, env.HWLAB_CODE_AGENT_AGENTRUN_MGR_URL, DEFAULT_AGENTRUN_MGR_URL); const url = new URL(raw); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index b63fa877..a4cd7aea 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -1105,7 +1105,13 @@ export async function handleCodeAgentChatResultHttp(request, response, url, opti label: "agentrun:result:poll-failed", errorCode: error?.code ?? "agentrun_result_poll_failed", message: error?.message ?? "AgentRun result polling failed", - waitingFor: "agentrun-result" + runId: result?.agentRun?.runId ?? null, + commandId: result?.agentRun?.commandId ?? null, + timeoutMs: numberOrNull(error?.timeoutMs), + timeoutStage: textValue(error?.timeoutStage) || null, + upstreamRoute: textValue(error?.route ?? error?.path) || null, + waitingFor: "agentrun-result", + valuesPrinted: false }); if (result?.agentRun && result.status === "running") { sendJson(response, 202, { @@ -1117,7 +1123,7 @@ export async function handleCodeAgentChatResultHttp(request, response, url, opti runnerTrace: compactRunnerTraceForResult(traceStore.snapshot(traceId), resultTraceEventLimit(options)), waitingFor: "agentrun-result", degraded: true, - error: { code: error?.code ?? "agentrun_result_poll_failed", message: error?.message ?? "AgentRun result polling failed" } + error: codeAgentRefreshErrorPayload(error, traceId, result?.agentRun, "agentrun_result_poll_failed") }); return; } @@ -1205,6 +1211,11 @@ async function resolveCodeAgentTurnStatusSnapshot(traceId, options) { label: "turn-status:result-sync-failed", errorCode: error?.code ?? "agentrun_result_poll_failed", message: error?.message ?? "AgentRun result polling failed", + runId: result?.agentRun?.runId ?? null, + commandId: result?.agentRun?.commandId ?? null, + timeoutMs: numberOrNull(error?.timeoutMs), + timeoutStage: textValue(error?.timeoutStage) || null, + upstreamRoute: textValue(error?.route ?? error?.path) || null, waitingFor: "agentrun-result", valuesPrinted: false }); @@ -1227,7 +1238,7 @@ async function resolveCodeAgentTurnStatusSnapshot(traceId, options) { const refreshedTrace = await refreshAgentRunTrace({ traceId, result: agentRunResult, options, traceStore }); agentRunResult = options.codeAgentChatResults?.get?.(traceId) ?? agentRunResult; if (traceNeedsCommandResultSync(agentRunResult, refreshedTrace)) { - const synced = await syncAgentRunChatResult({ traceId, currentResult: agentRunResult, options, traceStore, appendResultEvent: false, refreshEvents: false }); + const synced = await syncAgentRunChatResult({ traceId, currentResult: agentRunResult, options, traceStore, appendResultEvent: false, refreshEvents: false, forceResultSync: true }); agentRunResult = synced.result ?? agentRunResult; } if (isTraceCommandTerminalStatus(agentRunResult?.status)) { @@ -1306,13 +1317,33 @@ function codeAgentTurnStatusPayload({ traceId, result, snapshot, resultPollError retention: snapshotObject?.retention ?? null, eventCount: numberOrNull(snapshotObject?.eventCount ?? runnerTrace?.eventCount ?? events.length), error: resultPollError || refreshError - ? { code: resultPollError?.code ?? refreshError?.code ?? "turn_status_degraded", message: resultPollError?.message ?? refreshError?.message ?? "Code Agent turn status refresh degraded", valuesPrinted: false } + ? codeAgentRefreshErrorPayload(resultPollError ?? refreshError, traceId, resultObject?.agentRun ?? snapshotObject?.agentRun, "turn_status_degraded") : resultObject?.error ?? snapshotObject?.error ?? null, valuesRedacted: true, secretMaterialStored: false }; } +function codeAgentRefreshErrorPayload(error, traceId, agentRun, fallbackCode) { + return { + code: error?.code ?? fallbackCode, + layer: error?.layer ?? "agentrun", + category: error?.category ?? (error?.code === "agentrun_timeout" ? "upstream-timeout" : "upstream-refresh-failed"), + retryable: error?.retryable !== false, + message: error?.message ?? "Code Agent turn status refresh degraded", + traceId, + runId: agentRun?.runId ?? error?.runId ?? null, + commandId: agentRun?.commandId ?? error?.commandId ?? null, + method: error?.method ?? null, + route: error?.route ?? error?.path ?? null, + timeoutMs: numberOrNull(error?.timeoutMs), + timeoutStage: error?.timeoutStage ?? null, + upstreamStatus: numberOrNull(error?.statusCode), + managerHost: error?.managerHost ?? null, + valuesPrinted: false + }; +} + function normalizeTurnStatus(...values) { for (const value of values) { const text = textValue(value).toLowerCase().replace(/_/gu, "-"); @@ -2509,7 +2540,7 @@ export async function handleCodeAgentTraceHttp(request, response, url, options) const snapshot = await refreshAgentRunTrace({ traceId, result: agentRunResult, options, traceStore }); agentRunResult = options.codeAgentChatResults?.get?.(traceId) ?? agentRunResult; if (traceNeedsCommandResultSync(agentRunResult, snapshot)) { - const synced = await syncAgentRunChatResult({ traceId, currentResult: agentRunResult, options, traceStore, appendResultEvent: false, refreshEvents: false }); + const synced = await syncAgentRunChatResult({ traceId, currentResult: agentRunResult, options, traceStore, appendResultEvent: false, refreshEvents: false, forceResultSync: true }); agentRunResult = synced.result ?? agentRunResult; } if (isTraceCommandTerminalStatus(agentRunResult?.status)) { diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index f73c30d2..f4a351ef 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -78,7 +78,8 @@ function traceStorageKey(message: ChatMessage): string {