From e0ad20edc9ced95396542b01bd56cb30dae3c694 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:29:12 +0800 Subject: [PATCH] fix(workbench): fail visibly on projection fact write errors (#2046) --- docs/reference/cloud-workbench.md | 2 + internal/cloud/server-code-agent-http.ts | 6 + internal/cloud/workbench-projection-writer.ts | 107 ++++++++++++++++-- 3 files changed, 103 insertions(+), 12 deletions(-) diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index 0ea23cec..98a421dd 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -17,6 +17,8 @@ Workbench 页面和组件必须明确区分“未加载完成”和“已加载 Workbench 状态对象必须服从 UniDesk OA Web SPEC 中已经定下来的单一权威 API 绑定。Session rail 的会话集合只允许消费 `/v1/agent/conversations` 成功返回的 conversation 集合;当前 selected conversation 如果需要出现在左侧列表中,必须通过显式 query/path/body 传入稳定 conversation id,由该列表 API 在同一响应中返回,后端不得隐藏读取 workspace selected、localStorage、Web snapshot 或上一轮页面状态。前端不得用 workspace selected snapshot、stub、localStorage 或 route 状态补出 session tab,也不得把前端拼接出的 status、final response、markdown、running 动效或 stub 传回后端变成事实。Session 运行状态必须按 `sessionId` 绑定到单一 session 状态 API;Code Agent turn、trace 阅读和 final response 必须按 `traceId` 绑定到单一 trace/result snapshot API。任何权威 API 失败时只能保留上一份成功结果或显示未加载/错误态,不得切换到另一条 fallback 路径形成一条会话、旧 running 态或劣化 markdown。 +Workbench 投影写路径必须做到 0 隐式 fallback。Admission、projection event、terminal/finalizer 等上游写入如果无法把 session/message/turn/checkpoint facts 写入 durable read model,不能 `catch` 后返回空值继续表现为成功;admission 阶段必须显式失败并把错误传给调用方,后台投影阶段必须至少写入 trace diagnostic 和 OTel error span。只有成功落库的 Workbench facts 才能驱动控制页、观察页、session rail、耗时和 final response;前端或 read path 不得用内存 trace、local optimistic state、历史 snapshot 或多来源仲裁去修补失败写入。 + Cloud Web 的通用加载态使用 `web/hwlab-cloud-web/src/components/common/LoadingState.vue`。新增或修复页面加载态时优先复用该组件,并通过明确的 ready/loading 状态控制展示;不要在每个组件里重新实现一套 spinner、点状动画或默认占位数据。紧凑区域可以使用组件的 compact 形态,文案默认保持“加载中”。 Session rail 是该规则的高频区域。`/v1/agent/conversations` 还未返回时,即使 workspace 中已有 `selectedConversationId`、sessionId、traceId 或 selected conversation snapshot,也不能把选中 session stub 渲染成单条 `.session-tab`,更不能让它占满整个 session 列表高度。加载窗口应只显示 `LoadingState`,并隐藏当前 trace 元信息、复制/删除等依赖真实 active tab 的动作;待 conversations ready 后再渲染真实 session tabs,或在真实空集合时显示空态。 diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 36aa61a1..546d020f 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -963,6 +963,12 @@ async function recordCodeAgentTurnAdmission({ payload = {}, params = {}, options let ownerRecord = null; try { ownerRecord = await recordCodeAgentSessionOwner({ payload, params, options, status: "running" }); + if (!ownerRecord) { + const error = new Error("Code Agent turn admission did not durably persist Workbench session ownership."); + error.code = "workbench_admission_owner_not_persisted"; + error.valuesRedacted = true; + throw error; + } } catch (error) { throw codeAgentAdmissionUnavailableError(error, { params, traceId }); } diff --git a/internal/cloud/workbench-projection-writer.ts b/internal/cloud/workbench-projection-writer.ts index 63010c36..91acd446 100644 --- a/internal/cloud/workbench-projection-writer.ts +++ b/internal/cloud/workbench-projection-writer.ts @@ -5,6 +5,7 @@ import { createHash } from "node:crypto"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; +import { emitCodeAgentOtelSpan } from "./otel-trace.ts"; import { safeTraceId } from "./server-http-utils.ts"; import { createWorkbenchTurnProjection, normalizeWorkbenchStatus, projectionDiagnostics, TERMINAL_STATUSES } from "./workbench-turn-projection.ts"; @@ -23,7 +24,7 @@ export async function writeWorkbenchProjectionSession({ accessController, runtim status, session }); - await writeWorkbenchProjectionFacts({ + const factsWrite = await writeWorkbenchProjectionFacts({ runtimeStore, traceStore, traceId: safeId, @@ -38,8 +39,16 @@ export async function writeWorkbenchProjectionSession({ accessController, runtim payload, params }); + if (!factsWrite) { + throw workbenchProjectionError("workbench_projection_facts_not_written", "Workbench projection facts were not durably written."); + } return ownerRecord; } catch (error) { + emitWorkbenchProjectionOtel("workbench.projection.session.persist", safeId, "error", { + "workbench.projection.phase": "session-owner", + "workbench.projection.has_owner_user_id": Boolean(ownerUserId), + "workbench.projection.has_session_id": Boolean(sessionId) + }, error); if (safeId) appendProjectionDiagnostic(traceStore, safeId, { type: "session-owner", status: "degraded", @@ -48,7 +57,7 @@ export async function writeWorkbenchProjectionSession({ accessController, runtim message: error?.message ?? "Workbench projection session persistence failed.", valuesPrinted: false }); - return null; + throw error; } } @@ -232,12 +241,42 @@ export async function writeWorkbenchProjectionEvent({ runtimeStore = null, event updatedAt: projectedAt }] : [] }; - return runtimeStore.writeWorkbenchFacts({ facts }, { - traceId, - sessionId, - agentSessionId: requestMeta.agentSessionId ?? sessionId, - valuesPrinted: false - }); + try { + const result = await runtimeStore.writeWorkbenchFacts({ facts }, { + traceId, + sessionId, + agentSessionId: requestMeta.agentSessionId ?? sessionId, + valuesPrinted: false + }); + emitWorkbenchProjectionOtel("workbench.projection.event.write", traceId, "ok", { + ...workbenchProjectionFactCountAttributes(facts), + "workbench.projection.phase": terminal ? "event-terminal" : "event-running", + "workbench.projection.terminal": terminal, + "workbench.projection.suppressed_after_seal": suppressedAfterSeal, + "workbench.projection.session_id": sessionId, + "workbench.projection.turn_id": turnId + }); + return result; + } catch (error) { + emitWorkbenchProjectionOtel("workbench.projection.event.write", traceId, "error", { + ...workbenchProjectionFactCountAttributes(facts), + "workbench.projection.phase": terminal ? "event-terminal" : "event-running", + "workbench.projection.terminal": terminal, + "workbench.projection.suppressed_after_seal": suppressedAfterSeal, + "workbench.projection.session_id": sessionId, + "workbench.projection.turn_id": turnId + }, error); + appendProjectionDiagnostic(defaultCodeAgentTraceStore, traceId, { + type: "facts", + status: "degraded", + label: "projection-writer:event-facts:persist-failed", + errorCode: error?.code ?? "workbench_event_facts_persist_failed", + message: error?.message ?? "Workbench event facts persistence failed.", + terminal: false, + valuesPrinted: false + }); + throw error; + } } function eventProjectionAssistantMessageId(traceId, event = {}) { @@ -257,15 +296,30 @@ async function writeWorkbenchProjectionFacts({ runtimeStore = null, traceStore = const facts = buildWorkbenchProjectionFacts({ traceId, ownerUserId, ownerRole, sessionId, projectId, conversationId, threadId, status, session, payload, params, previousCheckpoint }); if (isEmptyFacts(facts)) return null; try { - return await runtimeStore.writeWorkbenchFacts({ facts }, { + const result = await runtimeStore.writeWorkbenchFacts({ facts }, { traceId: facts.checkpoints[0]?.traceId ?? traceId, sessionId: facts.sessions[0]?.sessionId ?? sessionId, ownerUserId, projectId, valuesPrinted: false }); + emitWorkbenchProjectionOtel("workbench.projection.facts.write", safeId, "ok", { + ...workbenchProjectionFactCountAttributes(facts), + "workbench.projection.phase": "session-facts", + "workbench.projection.terminal": facts.turns.some((turn) => turn.terminal === true), + "workbench.projection.session_id": facts.sessions[0]?.sessionId ?? sessionId ?? null, + "workbench.projection.turn_id": facts.turns[0]?.turnId ?? null + }); + return result; } catch (error) { const safeId = safeTraceId(traceId); + emitWorkbenchProjectionOtel("workbench.projection.facts.write", safeId, "error", { + ...workbenchProjectionFactCountAttributes(facts), + "workbench.projection.phase": "session-facts", + "workbench.projection.terminal": facts.turns.some((turn) => turn.terminal === true), + "workbench.projection.session_id": facts.sessions[0]?.sessionId ?? sessionId ?? null, + "workbench.projection.turn_id": facts.turns[0]?.turnId ?? null + }, error); if (safeId) appendProjectionDiagnostic(traceStore, safeId, { type: "facts", status: "degraded", @@ -274,7 +328,7 @@ async function writeWorkbenchProjectionFacts({ runtimeStore = null, traceStore = message: error?.message ?? "Workbench facts persistence failed.", terminal: false }); - return null; + throw error; } } @@ -362,8 +416,7 @@ function buildWorkbenchProjectionFacts({ traceId = null, ownerUserId = null, own sealed: projection.terminal, finalResponse: projectedFinalResponse, diagnostic, - finalResponse: checkpointFinalResponse, - assistantText: messageFinalText, + assistantText: finalText, userMessage: previousCheckpoint?.userMessage ?? null, timing, startedAt: timing.startedAt, @@ -399,6 +452,36 @@ function buildWorkbenchProjectionFacts({ traceId = null, ownerUserId = null, own }; } +function workbenchProjectionError(code, message) { + const error = new Error(message); + error.code = code; + error.valuesRedacted = true; + return error; +} + +function emitWorkbenchProjectionOtel(name, traceId, status = "ok", attributes = {}, error = null) { + void emitCodeAgentOtelSpan(name, safeTraceId(traceId) ?? "trc_unassigned", process.env, { + status, + error, + attributes: { + ...attributes, + "workbench.projection.zero_implicit_fallback": true, + "workbench.projection.error_visible": status === "error" + } + }); +} + +function workbenchProjectionFactCountAttributes(facts = {}) { + return { + "workbench.facts.sessions": Array.isArray(facts.sessions) ? facts.sessions.length : 0, + "workbench.facts.messages": Array.isArray(facts.messages) ? facts.messages.length : 0, + "workbench.facts.parts": Array.isArray(facts.parts) ? facts.parts.length : 0, + "workbench.facts.turns": Array.isArray(facts.turns) ? facts.turns.length : 0, + "workbench.facts.trace_events": Array.isArray(facts.traceEvents) ? facts.traceEvents.length : 0, + "workbench.facts.checkpoints": Array.isArray(facts.checkpoints) ? facts.checkpoints.length : 0 + }; +} + function workbenchProjectionInputMessages({ session = {}, payload = {}, params = {}, traceId = null, timestamp = null, terminal = false, terminalStatus = "completed", finalText = null } = {}) { if (Array.isArray(session?.messages) && session.messages.length > 0) return session.messages; if (Array.isArray(payload?.messages) && payload.messages.length > 0) return payload.messages;