fix(workbench): fail visibly on projection fact write errors (#2046)

This commit is contained in:
Lyon
2026-06-24 15:29:12 +08:00
committed by GitHub
parent fbc23d20a9
commit e0ad20edc9
3 changed files with 103 additions and 12 deletions
+2
View File
@@ -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 状态 APICode 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,或在真实空集合时显示空态。
+6
View File
@@ -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 });
}
+95 -12
View File
@@ -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;