diff --git a/docs/MDTODO/details/pure-kafka-live-single-step-debug/R2.6_Task_Report.md b/docs/MDTODO/details/pure-kafka-live-single-step-debug/R2.6_Task_Report.md new file mode 100644 index 00000000..daba6ca6 --- /dev/null +++ b/docs/MDTODO/details/pure-kafka-live-single-step-debug/R2.6_Task_Report.md @@ -0,0 +1,35 @@ +# R2.6 任务报告 + +## 目标与来源 + +- 来源:[HWLAB #2510](https://github.com/pikasTech/HWLAB/issues/2510)。 +- 固定诊断 trace:`ae0181ba9a18bad2a561157d312c3692`。 +- 目标是在纯 Kafka + SSE 架构内定位重复首层,禁止文本指纹去重、HTTP/数据库补洞、第二失败通道和新开真实 run。 + +## 离线证据与根因 + +- OTel 有界读取显示固定 trace 只有 `hwlab-cloud-api POST /v1/agent/chat` 一个 503 span;权威上游类型是 `schema-invalid`,错误层为 `agentrun`。 +- 失败时段候选 session `ses_d5511238-e999-48a8-a93e-eb3c3f93e540` 仍为 `messageCount=0`、`lastTraceId=null`,更新时间停在失败前。 +- `hwlab-cli kafka inspect order` 对该 session 完整扫描既有 `agentrun.event.v1` 与 `hwlab.event.v1` 后,两侧 `matchedCount=0`;共享 refresh handoff、Web reducer 与 DOM projection 也均为 0。没有读取事件值或 dump,也没有新建真实运行。 +- 因此首个重复环节不在 AgentRun event、HWLAB bridge、Kafka、SSE 或 reducer。失败发生在 durable admission 之前;Web 的 `projectLocalAdmissionFailure` 却伪造了一个 `workbench-local` terminal trace event,所以出现“运行记录 1 events”。同一错误随后又由 assistant 正文、synthetic timeline Error 与诊断摘要各显示一次。 + +## 修复方案 + +- 新增 pre-admission typed failure classifier。分类 authority 只读取正式 `failureKind`、显式 `retryable`、状态码和 correlation 字段;`rollout-in-progress` 只接受 AgentRun 权威 typed code,不按时间、503 或文案推断。 +- 保留 `failureKind/reason/retryable/userMessage/recoveryAction`,并透传 Workbench trace、upstream trace、run、command、dispatch intent 与 dispatch stage;尚未创建的 identity 显式为 `null`,admission state 为 `rejected-before-durable-dispatch`。 +- 覆盖 rollout、manager/service、admission/policy、runner capacity、provider/profile、Secret、image、workspace/materialization、lease/idempotency、dispatcher/runtime 与 unknown 类型。固定 `schema-invalid` 分类为不可重试的 `dispatcher-runtime-misconfigured`。 +- Web 不再把 HTTP admission rejection 制造成 trace/Kafka event,不再写假 terminal turn/session authority;只保留一个由稳定 assistant message/failure identity 拥有的失败正文。timeline Error 和诊断摘要依据 `failureIdentity + primaryPresentation + lifecycle` 收敛,不比较显示文本。 +- 诊断折叠区继续展示类型、恢复动作和 correlation;正式 Kafka failure 的原始 `runnerTrace.events` 与 raw HWLAB Event 数据不做删除或过滤。 +- optimistic session running authority 改为 HTTP durable admission 成功后才建立,避免未接纳请求污染 session 状态。 + +## 验证 + +- `bun test internal/cloud/code-agent-dispatch-failure.test.ts`:3/3 通过,覆盖 rollout transient、固定 schema-invalid permanent、provider/profile permanent 与 unknown 下钻。 +- `bun test internal/cloud/server-agent-chat.test.ts --test-name-pattern 'preserves typed schema-invalid'`:通过;503 响应保留完整 typed fields/correlation,且没有 false running/failed owner write。 +- `bun test web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts`:34/34 通过;固定 trace fixture 只有 `UserMessage + AssistantPart`,无 synthetic Error、无 local trace event;正式 Kafka failure 的两条 raw event 保持不变。 +- `bun run check:tsc`:通过 Web source scan、Vite/Vue build 与 source-shape 检查。 +- `git diff --check`:通过。 + +## 待完成验收 + +- 当前交付边界是未合并 PR,不手工部署或触发 CI/CD。PR 合并后应等待 GitHub webhook → mirror → PAC → CI/CD 自动发布,再用原 Workbench 入口验证失败正文只出现一次、无“运行记录 1 events”,并核对诊断字段;完成该运行面证据后再关闭 R2.6/#2510。 diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index b7067230..2b15661c 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -619,7 +619,17 @@ async function agentRunDispatchJson({ fetchImpl, managerUrl, path, method = "GET terminal: terminalOnFailure, valuesPrinted: false }, backendProfile)); - throw Object.assign(error, { retryAttempt, retryMax: policy.maxRetries, failureKind, retryExhausted: retryable && retryAttempt >= policy.maxRetries }); + throw Object.assign(error, { + retryAttempt, + retryMax: policy.maxRetries, + failureKind, + retryable, + retryExhausted: retryable && retryAttempt >= policy.maxRetries, + dispatchStage: stage, + runId: runId ?? error?.runId ?? error?.agentRunError?.runId ?? null, + commandId: commandId ?? error?.commandId ?? error?.agentRunError?.commandId ?? null, + dispatchIntentId: error?.dispatchIntentId ?? error?.agentRunError?.dispatchIntentId ?? null + }); } retryAttempt += 1; const retryDelayMs = agentRunDispatchRetryDelayMs(policy, retryAttempt); diff --git a/internal/cloud/code-agent-dispatch-failure.test.ts b/internal/cloud/code-agent-dispatch-failure.test.ts new file mode 100644 index 00000000..1535065c --- /dev/null +++ b/internal/cloud/code-agent-dispatch-failure.test.ts @@ -0,0 +1,60 @@ +// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-07-12-p0-admission-failure-identity. +// Responsibility: Typed pre-admission AgentRun failure classification regressions. + +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { classifyCodeAgentDispatchFailure } from "./code-agent-dispatch-failure.ts"; + +test("classifies rollout-in-progress as an explicit retryable transient failure", () => { + const failure = classifyCodeAgentDispatchFailure({ + failureKind: "rollout-in-progress", + statusCode: 503, + retryable: true, + dispatchStage: "run-create" + }); + + assert.equal(failure.failureKind, "rollout-in-progress"); + assert.equal(failure.reason, "rollout-in-progress"); + assert.equal(failure.retryable, true); + assert.equal(failure.recoveryAction, "wait-for-rollout"); + assert.match(failure.userMessage, /滚动更新/u); +}); + +test("classifies fixed diagnostic schema-invalid as permanent dispatcher/runtime misconfiguration", () => { + const failure = classifyCodeAgentDispatchFailure({ + failureKind: "schema-invalid", + statusCode: 400, + retryable: false, + dispatchStage: "command-create", + runId: "run_fixed_trace", + agentRunError: { + failureKind: "schema-invalid", + traceId: "trc_agentrun_upstream", + message: "command schema rejected" + } + }); + + assert.equal(failure.failureKind, "schema-invalid"); + assert.equal(failure.reason, "dispatcher-runtime-misconfigured"); + assert.equal(failure.retryable, false); + assert.equal(failure.recoveryAction, "repair-agentrun-dispatch-contract"); + assert.equal(failure.runId, "run_fixed_trace"); + assert.equal(failure.commandId, null); + assert.equal(failure.upstreamTraceId, "trc_agentrun_upstream"); + assert.match(failure.userMessage, /未进入实时事件流/u); +}); + +test("keeps provider/profile and unknown permanent failures distinguishable", () => { + const profile = classifyCodeAgentDispatchFailure({ failureKind: "provider-profile-unconfigured", statusCode: 422, retryable: false }); + assert.equal(profile.reason, "provider-profile-unconfigured"); + assert.equal(profile.recoveryAction, "configure-provider-profile"); + assert.equal(profile.retryable, false); + + const unknown = classifyCodeAgentDispatchFailure({ failureKind: "new-manager-failure", statusCode: 418, retryable: false }); + assert.equal(unknown.failureKind, "new-manager-failure"); + assert.equal(unknown.reason, "unknown-dispatch-failure"); + assert.equal(unknown.recoveryAction, "inspect-agentrun-diagnostic"); + assert.equal(unknown.retryable, false); + assert.match(unknown.userMessage, /未识别/u); +}); diff --git a/internal/cloud/code-agent-dispatch-failure.ts b/internal/cloud/code-agent-dispatch-failure.ts new file mode 100644 index 00000000..fcfc1306 --- /dev/null +++ b/internal/cloud/code-agent-dispatch-failure.ts @@ -0,0 +1,137 @@ +// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-07-12-p0-admission-failure-identity. +// Responsibility: Normalize typed AgentRun dispatch failures before durable admission. + +// rollout 仅接受上游类型化 failureKind 作为权威;HTTP 状态、耗时和展示文案都不能推断 rollout。 +const FAILURE_REASON_RULES = Object.freeze([ + rule("rollout-in-progress", ["rollout-in-progress", "rollout-progressing", "deployment-in-progress", "runtime-starting", "service-starting"]), + rule("manager-service-unavailable", ["agentrun-connect-failed", "agentrun-manager-fetch-failed", "agentrun-request-failed", "agentrun-timeout", "agentrun-unreachable", "manager-unavailable", "service-unavailable", "upstream-unavailable", "http-502", "http-503", "http-504", "fetch-failed", "network-error", "timeout"]), + rule("admission-policy-denied", ["admission-denied", "policy-denied", "forbidden", "permission-denied", "rbac-denied", "unauthorized"]), + rule("runner-capacity-exhausted", ["runner-capacity", "runner-capacity-exhausted", "capacity-exhausted", "no-runner-capacity", "runner-unavailable", "runner-quota-exceeded"]), + rule("provider-profile-unconfigured", ["provider-profile-unconfigured", "provider-profile-missing", "provider-not-configured", "profile-not-configured", "provider-unconfigured"]), + rule("secret-unavailable", ["secret-unavailable", "secret-missing", "secret-not-found", "credential-unavailable", "credential-missing"]), + rule("image-unavailable", ["image-unavailable", "image-not-found", "image-pull-failed", "image-pull-backoff", "invalid-image"]), + rule("workspace-materialization-failed", ["workspace-materialization-failed", "resource-bundle-materialization-failed", "workspace-unavailable", "gitbundle-unavailable", "workspace-checkout-failed"]), + rule("lease-idempotency-conflict", ["lease-conflict", "lease-lost", "idempotency-conflict", "command-conflict", "dispatch-intent-conflict", "already-claimed"]), + rule("dispatcher-runtime-misconfigured", ["schema-invalid", "config-invalid", "configuration-invalid", "dispatcher-misconfigured", "runtime-misconfigured", "agentrun-internal-url-required", "invalid-dispatch-contract"]) +]); + +const FAILURE_PRESENTATIONS = Object.freeze({ + "rollout-in-progress": { + userMessage: "AgentRun 正在滚动更新,本次请求尚未接纳;可稍后重试。", + recoveryAction: "wait-for-rollout" + }, + "manager-service-unavailable": { + userMessage: "AgentRun 管理服务暂时不可用,本次请求尚未接纳;请等待服务恢复后重试。", + recoveryAction: "retry-when-agentrun-service-ready" + }, + "admission-policy-denied": { + userMessage: "AgentRun 接纳策略拒绝了本次请求;请检查身份、租户与执行策略。", + recoveryAction: "review-admission-policy" + }, + "runner-capacity-exhausted": { + userMessage: "AgentRun 当前没有可用 Runner 容量,本次请求尚未接纳;请释放或扩容后重试。", + recoveryAction: "release-or-add-runner-capacity" + }, + "provider-profile-unconfigured": { + userMessage: "AgentRun Provider/Profile 未配置,本次请求无法接纳;请先完成对应 Profile 配置。", + recoveryAction: "configure-provider-profile" + }, + "secret-unavailable": { + userMessage: "AgentRun 所需 Secret 不可用,本次请求无法接纳;请恢复 YAML 引用的 Secret。", + recoveryAction: "restore-referenced-secret" + }, + "image-unavailable": { + userMessage: "AgentRun Runner 镜像不可用,本次请求无法接纳;请发布或修正目标镜像。", + recoveryAction: "publish-or-correct-runner-image" + }, + "workspace-materialization-failed": { + userMessage: "AgentRun Workspace/Resource Bundle 物化失败,本次请求无法继续;请检查仓库与资源包引用。", + recoveryAction: "repair-workspace-materialization" + }, + "lease-idempotency-conflict": { + userMessage: "AgentRun Lease/Idempotency 发生冲突;请核对已有 Run/Command 后再重试。", + recoveryAction: "inspect-existing-lease-or-idempotent-command" + }, + "dispatcher-runtime-misconfigured": { + userMessage: "AgentRun 调度契约或运行面配置不匹配,本次请求未进入实时事件流;请展开诊断修复配置。", + recoveryAction: "repair-agentrun-dispatch-contract" + }, + "unknown-dispatch-failure": { + userMessage: "AgentRun 返回了未识别的调度失败,本次请求尚未接纳;请展开诊断定位。", + recoveryAction: "inspect-agentrun-diagnostic" + } +}); + +export function classifyCodeAgentDispatchFailure(error = {}) { + const upstream = record(error?.agentRunError); + const nested = record(upstream?.error); + const failureKind = normalizedFailureKind(firstText(error?.failureKind, upstream?.failureKind, nested?.failureKind, error?.code, upstream?.code, nested?.code, "agentrun-dispatch-error")); + const statusCode = firstInteger(error?.statusCode, error?.status, upstream?.statusCode, upstream?.status, nested?.statusCode); + const reason = dispatchFailureReason(failureKind, statusCode); + const presentation = FAILURE_PRESENTATIONS[reason] ?? FAILURE_PRESENTATIONS["unknown-dispatch-failure"]; + const retryable = firstBoolean(error?.retryable, upstream?.retryable, nested?.retryable, defaultRetryable(reason, statusCode)); + return { + failureKind, + reason, + retryable, + userMessage: presentation.userMessage, + recoveryAction: presentation.recoveryAction, + message: firstText(error?.message, upstream?.message, nested?.message, "AgentRun dispatch failed before durable admission."), + statusCode, + dispatchStage: firstText(error?.dispatchStage, error?.stage, upstream?.dispatchStage, upstream?.stage), + upstreamTraceId: firstText(upstream?.traceId, nested?.traceId), + runId: firstText(error?.runId, upstream?.runId, upstream?.run?.id, nested?.runId), + commandId: firstText(error?.commandId, upstream?.commandId, upstream?.command?.id, nested?.commandId), + dispatchIntentId: firstText(error?.dispatchIntentId, upstream?.dispatchIntentId, upstream?.dispatchIntent?.id, nested?.dispatchIntentId), + valuesPrinted: false + }; +} + +export function dispatchFailureReason(failureKind, statusCode = null) { + const normalized = normalizedFailureKind(failureKind); + const matched = FAILURE_REASON_RULES.find((candidate) => candidate.kinds.has(normalized)); + if (matched) return matched.reason; + if (statusCode === 401 || statusCode === 403) return "admission-policy-denied"; + if (statusCode === 409) return "lease-idempotency-conflict"; + if ([502, 503, 504].includes(statusCode)) return "manager-service-unavailable"; + return "unknown-dispatch-failure"; +} + +function rule(reason, kinds) { + return Object.freeze({ reason, kinds: new Set(kinds) }); +} + +function defaultRetryable(reason, statusCode) { + if (["rollout-in-progress", "manager-service-unavailable", "runner-capacity-exhausted", "lease-idempotency-conflict"].includes(reason)) return true; + if (["admission-policy-denied", "provider-profile-unconfigured", "secret-unavailable", "image-unavailable", "workspace-materialization-failed", "dispatcher-runtime-misconfigured"].includes(reason)) return false; + return Number.isInteger(statusCode) && statusCode >= 500; +} + +function normalizedFailureKind(value) { + const text = String(value ?? "").trim().toLowerCase().replace(/_/gu, "-").replace(/[^a-z0-9.-]+/gu, "-").replace(/^-+|-+$/gu, ""); + return text || "agentrun-dispatch-error"; +} + +function record(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : null; +} + +function firstText(...values) { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return null; +} + +function firstInteger(...values) { + for (const value of values) { + const number = Number(value); + if (Number.isInteger(number) && number > 0) return number; + } + return null; +} + +function firstBoolean(...values) { + for (const value of values) if (typeof value === "boolean") return value; + return false; +} diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 9bfb3549..e95197df 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -1208,6 +1208,93 @@ test("cloud api rejects billing failure before durable AgentRun admission withou } }); +test("cloud api preserves typed schema-invalid admission failure without claiming realtime projection (#2510)", async () => { + const traceId = "trc_issue2510_schema_invalid"; + const sessionId = "ses_issue2510_schema_invalid"; + const conversationId = "cnv_issue2510_schema_invalid"; + const ownerSessions = new Map([[sessionId, testAgentSessionRecord({ sessionId, conversationId, projectId: "prj_hwpod_workbench", status: "idle" })]]); + const ownerWrites = []; + const agentRunServer = createHttpServer(async (request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + for await (const _chunk of request) void _chunk; + response.setHeader("content-type", "application/json"); + if (request.method === "POST" && url.pathname === "/api/v1/sessions") { + response.writeHead(200); + response.end(`${JSON.stringify({ ok: true, data: { session: { sessionId: "ses_agentrun_issue2510", backendProfile: "gpt-pika", metadata: {} } } })}\n`); + return; + } + if (request.method === "POST" && url.pathname === "/api/v1/runs") { + response.writeHead(422); + response.end(`${JSON.stringify({ ok: false, failureKind: "schema-invalid", message: "run schema rejected", traceId: "trc_agentrun_issue2510" })}\n`); + return; + } + response.writeHead(404); + response.end(`${JSON.stringify({ ok: false, failureKind: "not-found", message: `unexpected ${request.method} ${url.pathname}` })}\n`); + }); + await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve)); + const agentRunPort = agentRunServer.address().port; + const server = createCloudApiServer({ + env: { + ...TEST_REALTIME_CAPABILITIES_DISABLED, + HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01", + AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`, + HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1", + HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID: "D601", + HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567", + HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "gpt-pika", + HWLAB_ENVIRONMENT: "v03", + HWLAB_GITOPS_PROFILE: "v03" + }, + accessController: { + required: false, + async authenticate() { return { ok: true, actor: TEST_AGENT_ACTOR, session: TEST_AUTH_SESSION }; }, + async getAgentSession(requestedSessionId) { return ownerSessions.get(requestedSessionId) ?? null; }, + async recordAgentSessionOwner(input) { + ownerWrites.push(input); + const record = testAgentSessionRecord({ ...(ownerSessions.get(input.sessionId) ?? {}), ...input }); + ownerSessions.set(record.sessionId, record); + return record; + } + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { "content-type": "application/json", "x-trace-id": traceId }, + body: JSON.stringify({ sessionId, conversationId, projectId: "prj_hwpod_workbench", message: "hi", providerProfile: "gpt-pika" }) + }); + assert.equal(response.status, 503); + const payload = await response.json(); + assert.equal(payload.accepted, false); + assert.equal(payload.status, "failed"); + assert.equal(payload.traceId, traceId); + assert.equal(payload.error.code, "schema-invalid"); + assert.equal(payload.error.failureKind, "schema-invalid"); + assert.equal(payload.error.reason, "dispatcher-runtime-misconfigured"); + assert.equal(payload.error.retryable, false); + assert.equal(payload.error.recoveryAction, "repair-agentrun-dispatch-contract"); + assert.equal(payload.error.failureIdentity, "admission:msg_issue2510_schema_invalid_agent"); + assert.equal(payload.error.primaryPresentation, "assistant-message"); + assert.equal(payload.error.traceId, traceId); + assert.equal(payload.error.upstreamTraceId, "trc_agentrun_issue2510"); + assert.equal(payload.error.runId, null); + assert.equal(payload.error.commandId, null); + assert.equal(payload.error.admissionState, "rejected-before-durable-dispatch"); + assert.equal(payload.admission.state, "rejected-before-durable-dispatch"); + assert.equal(payload.admission.durableDispatch, false); + assert.equal(payload.admission.runId, null); + assert.equal(payload.admission.commandId, null); + assert.match(payload.error.userMessage, /未进入实时事件流/u); + assert.equal(ownerWrites.some((write) => write.traceId === traceId && ["running", "failed"].includes(write.status)), false); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + await new Promise((resolve, reject) => agentRunServer.close((error) => error ? reject(error) : resolve())); + } +}); + test("cloud api AgentRun adapter reports persistent thread resume when a completed run needs a new runner", async () => { const calls = []; const hwlabSessionId = "ses_server-test-thread-resume"; diff --git a/internal/cloud/server-code-agent-admission-http.ts b/internal/cloud/server-code-agent-admission-http.ts index 10bd3f0e..036e9aee 100644 --- a/internal/cloud/server-code-agent-admission-http.ts +++ b/internal/cloud/server-code-agent-admission-http.ts @@ -14,6 +14,7 @@ import { submitAgentRunChatTurn } from "./code-agent-agentrun-adapter.ts"; import { createCodeAgentErrorPayload, handleCodeAgentChat } from "./code-agent-chat.ts"; +import { classifyCodeAgentDispatchFailure } from "./code-agent-dispatch-failure.ts"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { codeAgentSessionLifecycleSummary } from "./code-agent-session-lifecycle.ts"; import { messageAuthorityTextValue } from "./code-agent-agentrun-prompt.ts"; @@ -1467,6 +1468,27 @@ function codeAgentSubmissionUnavailablePayload(failure = {}, { params = {}, opti const error = source.error ?? {}; const blocker = source.blocker ?? null; const message = error.message ?? blocker?.summary ?? "Code Agent submission failed before durable admission."; + const lifecycle = codeAgentTurnLifecycleFields(traceId, params); + const resolvedTraceId = safeTraceId(traceId) || null; + const failureIdentity = textValue(error.failureIdentity) || `admission:${lifecycle.assistantMessageId}`; + const admission = source.admission && typeof source.admission === "object" ? source.admission : null; + const failureFields = { + failureKind: textValue(error.failureKind ?? blocker?.failureKind ?? error.code ?? blocker?.code) || "submission-unavailable", + reason: textValue(error.reason ?? blocker?.reason) || "unknown-dispatch-failure", + recoveryAction: textValue(error.recoveryAction ?? blocker?.recoveryAction) || "inspect-agentrun-diagnostic", + failureIdentity, + primaryPresentation: "assistant-message", + traceId: resolvedTraceId, + turnId: lifecycle.turnId, + userMessageId: lifecycle.userMessageId, + assistantMessageId: lifecycle.assistantMessageId, + runId: textValue(error.runId ?? blocker?.runId ?? admission?.runId) || null, + commandId: textValue(error.commandId ?? blocker?.commandId ?? admission?.commandId) || null, + dispatchIntentId: textValue(error.dispatchIntentId ?? blocker?.dispatchIntentId ?? admission?.dispatchIntentId) || null, + admissionState: textValue(error.admissionState ?? blocker?.admissionState ?? admission?.state) || "rejected-before-durable-dispatch", + durableDispatch: admission?.durableDispatch === true, + valuesPrinted: false + }; return { ...source, ok: false, @@ -1474,19 +1496,29 @@ function codeAgentSubmissionUnavailablePayload(failure = {}, { params = {}, opti status: "failed", traceId: safeTraceId(traceId) || null, ...codeAgentOtelTraceFields(traceId, options.env ?? process.env), - ...codeAgentTurnLifecycleFields(traceId, params), + ...lifecycle, conversationId: safeConversationId(params.conversationId) || null, sessionId: safeSessionId(params.sessionId) || null, threadId: safeOpaqueId(params.threadId) || null, error: { ...error, + ...failureFields, code: error.code ?? blocker?.code ?? "submission_unavailable", layer: error.layer ?? blocker?.layer ?? "admission", retryable: error.retryable !== false, message, route: error.route ?? "/v1/agent/chat" }, - blocker: blocker ? { ...blocker, summary: blocker.summary ?? message } : null, + blocker: blocker ? { ...blocker, ...failureFields, summary: blocker.summary ?? message } : null, + admission: { + ...(admission ?? {}), + state: failureFields.admissionState, + runId: failureFields.runId, + commandId: failureFields.commandId, + dispatchIntentId: failureFields.dispatchIntentId, + durableDispatch: failureFields.durableDispatch, + valuesPrinted: false + }, valuesRedacted: true, secretMaterialStored: false }; @@ -1501,6 +1533,7 @@ function codeAgentPromotionUnavailableError(error, { payload = {}, params = {}, retryable: true, message, userMessage: "AgentRun 已持久接纳本次请求,但本地可见性提交尚未完成;请使用同一 traceId 重试补提交。", + admissionState: "promotion-pending", route: "/v1/agent/chat" }, blocker: { @@ -1508,6 +1541,14 @@ function codeAgentPromotionUnavailableError(error, { payload = {}, params = {}, layer: "admission", retryable: true, summary: message + }, + admission: { + state: "promotion-pending", + runId: payload.agentRun?.runId ?? null, + commandId: payload.agentRun?.commandId ?? null, + dispatchIntentId: payload.agentRun?.dispatchIntentId ?? null, + durableDispatch: payload.agentRun?.durableDispatch === true, + valuesPrinted: false } }, { params, options, traceId }); responsePayload.admission = { @@ -1559,23 +1600,44 @@ function codeAgentAdmissionUnavailablePayload({ error, params = {}, options = {} } function codeAgentDispatchFailure(error) { - const message = error?.message ?? "AgentRun dispatch failed"; + const failure = classifyCodeAgentDispatchFailure(error); + const failureFields = { + failureKind: failure.failureKind, + reason: failure.reason, + retryable: failure.retryable, + userMessage: failure.userMessage, + recoveryAction: failure.recoveryAction, + dispatchStage: failure.dispatchStage, + upstreamTraceId: failure.upstreamTraceId, + runId: failure.runId, + commandId: failure.commandId, + dispatchIntentId: failure.dispatchIntentId, + admissionState: "rejected-before-durable-dispatch", + valuesPrinted: false + }; return { statusCode: 503, payload: { error: { - code: error?.code ?? "agentrun_dispatch_failed", + ...failureFields, + code: failure.failureKind, layer: "agentrun", - retryable: true, - message, - userMessage: "AgentRun 调度失败;错误已提交到 Workbench 实时投影。", + message: failure.message, route: "/v1/agent/chat" }, blocker: { - code: error?.code ?? "agentrun_dispatch_failed", + ...failureFields, + code: failure.failureKind, layer: "agentrun", - retryable: true, - summary: message + summary: failure.message + }, + admission: { + state: "rejected-before-durable-dispatch", + runId: failure.runId, + commandId: failure.commandId, + dispatchIntentId: failure.dispatchIntentId, + durableDispatch: false, + valuesPrinted: false } } }; diff --git a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts index 06a9338d..abacb847 100644 --- a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts @@ -18,6 +18,7 @@ import { composeWorkbenchScopedKey, splitWorkbenchScopedKey, workbenchPathKey, w import { createSafeStorageRuntime, isStorageQuotaError, migrateLegacyStorage, normalizePersistedValue, readJsonStorage, removePersistedTarget, removeStorageKey, writeJsonStorage, type StorageLike } from "../src/utils/safe-storage.ts"; import { checkWorkbenchHealth, createWorkbenchHealthProbeCache } from "../src/utils/workbench-health.ts"; import { messageDiagnosticView } from "../src/utils/workbench-error-runtime.ts"; +import { projectRejectedWorkbenchAdmission } from "../src/stores/workbench-admission-failure.ts"; import { WORKBENCH_TIMELINE_OPENCODE_PARITY, buildWorkbenchTimelineRows, normalizeWorkbenchTimelineMessages, workbenchTimelineSignature } from "../src/stores/workbench-timeline-model.ts"; import { reduceWorkbenchRealtimeEvent, workbenchRealtimeEventIsBusinessActivity } from "../src/stores/workbench-event-reducer.ts"; import { reduceWorkbenchLiveKafkaMessageState, workbenchAgentMessageIdForTrace } from "../src/stores/workbench-live-kafka-event.ts"; @@ -123,6 +124,99 @@ test("Error runtime owns Workbench message diagnostic view model", () => { assert.equal(sealed.visible, false); }); +test("fixed pre-admission diagnostic projects one assistant failure and no local trace event", () => { + const traceId = "trc_fixed_dispatch_failure"; + const userMessage: ChatMessage = { id: "msg_fixed_user", messageId: "msg_fixed_user", role: "user", title: "用户", text: "hi", status: "sent", createdAt: "2026-07-12T08:33:19.000Z", sessionId: "ses_fixed", traceId }; + const pending = agentMessage({ + id: "msg_fixed_agent", + messageId: "msg_fixed_agent", + status: "running", + text: "", + traceId, + sessionId: "ses_fixed", + createdAt: "2026-07-12T08:33:19.000Z" + }); + const projection = { + projectionStatus: "blocked", + projectionHealth: "degraded", + blocker: { + code: "schema-invalid", + failureKind: "schema-invalid", + reason: "dispatcher-runtime-misconfigured", + retryable: false, + userMessage: "AgentRun 调度契约或运行面配置不匹配,本次请求未进入实时事件流;请展开诊断修复配置。" + } + } as NonNullable; + const failed = projectRejectedWorkbenchAdmission(pending, { + message: projection.blocker?.userMessage ?? "failed", + error: { + code: "schema-invalid", + failureKind: "schema-invalid", + reason: "dispatcher-runtime-misconfigured", + retryable: false, + recoveryAction: "repair-agentrun-dispatch-contract", + failureIdentity: "admission:msg_fixed_agent", + primaryPresentation: "assistant-message", + admissionState: "rejected-before-durable-dispatch", + traceId, + runId: null, + commandId: null, + diagnostic: { + contractVersion: "hwlab-error-diagnostic-v1", + traceId: "ae0181ba9a18bad2a561157d312c3692", + code: "schema-invalid", + layer: "agentrun", + retryable: false, + valuesPrinted: false + } + }, + projection, + submittedAt: "2026-07-12T08:33:19.000Z", + finishedAt: "2026-07-12T08:33:19.057Z" + }); + + assert.equal(failed.runnerTrace, null); + assert.deepEqual(failed.traceEvents, []); + assert.equal((failed.admission as { state?: string }).state, "rejected-before-durable-dispatch"); + assert.equal(failed.error?.failureKind, "schema-invalid"); + assert.equal(failed.error?.reason, "dispatcher-runtime-misconfigured"); + assert.equal(failed.error?.recoveryAction, "repair-agentrun-dispatch-contract"); + assert.equal(failed.error?.runId, null); + assert.equal(failed.error?.commandId, null); + + const rows = buildWorkbenchTimelineRows([userMessage, failed]); + assert.deepEqual(rows.map((row) => row.type), ["UserMessage", "AssistantPart"]); + assert.equal(rows.filter((row) => row.type === "Error").length, 0); + const diagnostic = messageDiagnosticView(failed); + assert.equal(diagnostic.visible, true); + assert.equal(diagnostic.showMessage, false); + assert.equal(diagnostic.apiError?.failureKind, "schema-invalid"); + assert.equal(diagnostic.apiError?.reason, "dispatcher-runtime-misconfigured"); + assert.equal(diagnostic.apiError?.recoveryAction, "repair-agentrun-dispatch-contract"); + assert.equal(diagnostic.diagnostic?.traceId, "ae0181ba9a18bad2a561157d312c3692"); +}); + +test("formal Kafka failure keeps raw events and a distinct error row", () => { + const events = [ + { sourceEventId: "evt_formal_1", sourceSeq: 1, type: "error", failureKind: "provider-unavailable", message: "provider unavailable" }, + { sourceEventId: "evt_formal_2", sourceSeq: 2, type: "result", terminal: true, failureKind: "provider-unavailable" } + ]; + const formal = agentMessage({ + id: "msg_formal_agent", + messageId: "msg_formal_agent", + status: "failed", + text: "", + traceId: "trc_formal_failure", + error: { failureKind: "provider-unavailable", message: "provider unavailable", retryable: true }, + runnerTrace: { traceId: "trc_formal_failure", status: "failed", events, eventCount: 2 } + }); + + const rows = buildWorkbenchTimelineRows([formal]); + assert.equal(rows.filter((row) => row.type === "Error").length, 1); + assert.equal(formal.runnerTrace?.events?.length, 2); + assert.deepEqual(formal.runnerTrace?.events, events); +}); + test("scoped cache keeps OpenCode LRU and TTL semantics", () => { const disposed: string[] = []; let clock = 0; diff --git a/web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue b/web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue index a39a625a..dcab6f38 100644 --- a/web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue +++ b/web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue @@ -26,6 +26,12 @@ const facts = computed(() => { return [ fact("trace_id", firstString(diagnostic?.traceId, props.apiError?.traceId)), fact("requestId", firstString(diagnostic?.requestId, props.apiError?.requestId)), + fact("workbenchTraceId", firstString(props.apiError?.traceId)), + fact("upstreamTraceId", firstString(props.apiError?.upstreamTraceId)), + fact("runId", firstString(props.apiError?.runId)), + fact("commandId", firstString(props.apiError?.commandId)), + fact("failureKind", firstString(props.apiError?.failureKind)), + fact("reason", firstString(props.apiError?.reason)), fact("code", firstValue(props.apiError?.code, diagnostic?.code)), fact("layer", layerText(diagnostic, props.apiError)), fact("route", firstString(diagnostic?.route, props.apiError?.route)), @@ -36,6 +42,8 @@ const facts = computed(() => { fact("retryDelayMs", firstValue(diagnostic?.retryDelayMs, props.apiError?.retryDelayMs)), fact("nextRetryAt", firstString(diagnostic?.nextRetryAt, props.apiError?.nextRetryAt)), fact("retryExhausted", firstBooleanText(diagnostic?.retryExhausted, props.apiError?.retryExhausted)), + fact("recoveryAction", firstString(props.apiError?.recoveryAction)), + fact("admissionState", firstString(props.apiError?.admissionState)), fact("valuesPrinted", valuesPrinted) ].filter((item): item is { label: string; value: string } => Boolean(item)); }); diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index 65de36ba..7c742c06 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -86,7 +86,7 @@ function acknowledgeVisibleMessages(): void { - + - + diff --git a/web/hwlab-cloud-web/src/stores/workbench-admission-failure.ts b/web/hwlab-cloud-web/src/stores/workbench-admission-failure.ts new file mode 100644 index 00000000..3efddf3e --- /dev/null +++ b/web/hwlab-cloud-web/src/stores/workbench-admission-failure.ts @@ -0,0 +1,80 @@ +// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-07-12-p0-admission-failure-identity. +// Responsibility: Project a rejected HTTP admission as one local assistant message, never as a Kafka/trace event. + +import type { ChatMessage, ProjectionDiagnostic, WorkbenchTurnTimingProjection } from "@/types"; + +export interface RejectedWorkbenchAdmissionInput { + message: string; + error: ChatMessage["error"]; + projection: ProjectionDiagnostic; + submittedAt: string; + finishedAt?: string; +} + +export function projectRejectedWorkbenchAdmission(message: ChatMessage, input: RejectedWorkbenchAdmissionInput): ChatMessage { + const finishedAt = timestamp(input.finishedAt) ?? new Date().toISOString(); + const startedAt = timestamp(input.submittedAt) ?? finishedAt; + const durationMs = Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt)); + const sourceError = input.error ?? {}; + const failureIdentity = firstText(sourceError.failureIdentity, `admission:${message.messageId || message.id}`) ?? `admission:${message.id}`; + const admissionState = firstText(sourceError.admissionState, "rejected-before-durable-dispatch") ?? "rejected-before-durable-dispatch"; + const error = { + ...sourceError, + failureKind: firstText(sourceError.failureKind, sourceError.code, "submission-unavailable"), + reason: firstText(sourceError.reason, "unknown-dispatch-failure"), + recoveryAction: firstText(sourceError.recoveryAction, sourceError.retryable === true ? "retry-submit" : "inspect-agentrun-diagnostic"), + failureIdentity, + primaryPresentation: "assistant-message", + admissionState, + traceId: firstText(sourceError.traceId, message.traceId), + runId: firstText(sourceError.runId), + commandId: firstText(sourceError.commandId), + dispatchIntentId: firstText(sourceError.dispatchIntentId), + valuesPrinted: false + } as NonNullable; + const timing = { + startedAt, + lastEventAt: finishedAt, + finishedAt, + durationMs, + valuesRedacted: true + } as WorkbenchTurnTimingProjection; + return { + ...message, + status: "failed", + text: input.message, + error, + projection: input.projection, + projectionStatus: input.projection.projectionStatus ?? null, + projectionHealth: input.projection.projectionHealth ?? null, + blocker: input.projection.blocker ?? null, + runnerTrace: null, + traceEvents: [], + traceAutoLifecycle: null, + admission: { + state: admissionState, + durableDispatch: false, + runId: error.runId ?? null, + commandId: error.commandId ?? null, + dispatchIntentId: error.dispatchIntentId ?? null, + valuesPrinted: false + }, + failureIdentity, + timing, + startedAt, + lastEventAt: finishedAt, + finishedAt, + durationMs, + updatedAt: finishedAt + }; +} + +function timestamp(value: unknown): string | null { + if (typeof value !== "string" || !value.trim() || !Number.isFinite(Date.parse(value))) return null; + return new Date(Date.parse(value)).toISOString(); +} + +function firstText(...values: unknown[]): string | null { + for (const value of values) if (typeof value === "string" && value.trim()) return value.trim(); + return null; +} diff --git a/web/hwlab-cloud-web/src/stores/workbench-timeline-model.ts b/web/hwlab-cloud-web/src/stores/workbench-timeline-model.ts index 088eb47c..6a3aebd1 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-timeline-model.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-timeline-model.ts @@ -8,6 +8,7 @@ import type { ChatMessage } from "@/types"; import { firstScopePart, workbenchTimelineRowScopeKey } from "@/utils/workbench-key"; +import { messageOwnsPrimaryFailurePresentation } from "@/utils/workbench-error-runtime"; export type WorkbenchTimelineRowType = "TurnGap" | "CommentStrip" | "UserMessage" | "TurnDivider" | "AssistantPart" | "Thinking" | "Retry" | "DiffSummary" | "Error"; @@ -89,7 +90,7 @@ export function buildWorkbenchTimelineRows(messages: ChatMessage[]): WorkbenchTi if (messageIsThinking(message)) rows.push(syntheticRow("Thinking", userMessageId, sessionId, traceId, { reasoningHeading: reasoningHeading(message) })); if (messageIsRetry(message)) rows.push(syntheticRow("Retry", userMessageId, sessionId, traceId)); const errorText = messageErrorText(message); - if (errorText) rows.push(syntheticRow("Error", userMessageId, sessionId, traceId, { text: errorText })); + if (errorText && !messageOwnsPrimaryFailurePresentation(message)) rows.push(syntheticRow("Error", userMessageId, sessionId, traceId, { text: errorText })); const diffs = summaryDiffs(message); if (diffs.length > 0 && !messageIsActive(message)) rows.push(syntheticRow("DiffSummary", userMessageId, sessionId, traceId, { diffs })); }); diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index b1fb746d..fb9f05a3 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -23,6 +23,7 @@ import { cleanupWorkbenchServerStateSessions, selectActiveMessages, selectActive import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "./workbench-session-cache"; import { reduceWorkbenchRealtimeEvent, workbenchRealtimeEventIsBusinessActivity, type WorkbenchRealtimeAction } from "./workbench-event-reducer"; import { projectWorkbenchLiveKafkaMessage, projectWorkbenchLiveKafkaUserMessage, workbenchAgentMessageIdForTrace, workbenchLiveKafkaEnvelope, workbenchLiveKafkaProjectionTarget, workbenchUserMessageIdForTrace } from "./workbench-live-kafka-event"; +import { projectRejectedWorkbenchAdmission } from "./workbench-admission-failure"; import { workbenchHistoryAuthorityPolicy } from "./workbench-kafka-refresh-policy"; import { messageHasSealedTerminalResult, messageIsSealedTerminal, traceAuthorityIsSealed } from "./workbench-terminal-authority"; import { boundedProjectionMessageLimit, mergeBoundedProjectionMessages, selectProjectionMessageWindow, traceProjectionIsTerminalSealed } from "./workbench-message-projection-budget"; @@ -779,7 +780,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { const user = makeMessage("user", value, "sent", { id: userMessageId, messageId: userMessageId, traceId, sessionId, threadId, title: "用户", createdAt: submittedAt, updatedAt: submittedAt }); const pending = makeMessage("agent", "", "running", { id: agentMessageId, messageId: agentMessageId, traceId, sessionId, threadId, title: "Code Agent", retryInput: value, traceAutoLifecycle: "running", createdAt: submittedAt, updatedAt: submittedAt, ...optimisticRunningTimingPatch(submittedAt) }); appendActiveMessages(user, pending); - projectOptimisticRunningTurn({ sessionId, threadId, traceId, userText: value }); currentRequest.value = { traceId, sessionId, threadId, status: "running" }; } startWorkbenchSubmitJourney({ traceId, sessionId, entry: submitEntry, backend: providerProfile.value, transport: "sse" }); @@ -802,7 +802,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { restartRealtime("steer-not-admitted"); return false; } - projectLocalAdmissionFailure({ traceId, sessionId, threadId, message: failureMessage, error: failureError, projection, submittedAt }); + projectRejectedAdmissionFailure({ traceId, message: failureMessage, error: failureError, projection, submittedAt }); failWorkbenchSubmitJourney(traceId, response.status === 0 ? "network" : "error"); await clearActiveTrace(traceId, steerMode && response.status === 404 ? "steer-trace-not-found" : "submit-not-admitted"); chatPending.value = false; @@ -825,6 +825,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { restartRealtime("steer"); return true; } + projectOptimisticRunningTurn({ sessionId, threadId, traceId, userText: value }); const canonicalTraceId = alignOptimisticTurnMessages(traceId, response.data); if (canonicalTraceId !== traceId) { reduceServerState({ type: "turn.forget", traceId }); @@ -1687,91 +1688,12 @@ export const useWorkbenchStore = defineStore("workbench", () => { scheduleSessionListRefresh(selectedSessionId.value, runtimePolicy.sessionListTerminalRefreshDelayMs); } - function projectLocalAdmissionFailure(input: { traceId: string; sessionId: string; threadId: string | null; message: string; error: ChatMessage["error"]; projection: ProjectionDiagnostic; submittedAt: string }): void { + function projectRejectedAdmissionFailure(input: { traceId: string; message: string; error: ChatMessage["error"]; projection: ProjectionDiagnostic; submittedAt: string }): void { const finishedAt = new Date().toISOString(); - const startedAt = firstNonEmptyString(input.submittedAt, finishedAt) ?? finishedAt; - const durationMs = positiveDurationBetween(startedAt, finishedAt) ?? 1000; - const timing = { startedAt, lastEventAt: finishedAt, finishedAt, durationMs, valuesRedacted: true } as WorkbenchTurnTimingProjection; - const event = { - projectedSeq: 1, - ts: finishedAt, - createdAt: finishedAt, - label: "workbench:admission:failed", - type: "event", - status: "failed", - message: input.message, - elapsedMs: durationMs, - terminal: true, - traceId: input.traceId, - sessionId: input.sessionId, - threadId: input.threadId, - source: "workbench-web", - projection: input.projection, - projectionStatus: input.projection.projectionStatus ?? null, - projectionHealth: input.projection.projectionHealth ?? null, - error: input.error, - valuesRedacted: true - } as TraceEvent; - const runnerTrace = { - traceId: input.traceId, - sessionId: input.sessionId, - threadId: input.threadId ?? undefined, - status: "failed", - traceStatus: "failed", - events: [event], - eventCount: 1, - eventsCompacted: false, - fullTraceLoaded: true, - hasMore: false, - truncated: false, - nextProjectedSeq: 1, - range: { afterProjectedSeq: 0, fromProjectedSeq: 1, toProjectedSeq: 1, limit: runtimePolicy.traceDetailPageLimit, returned: 1, total: 1 }, - lastEventLabel: "workbench:admission:failed", - error: input.error ?? undefined, - projection: input.projection, - projectionStatus: input.projection.projectionStatus ?? null, - projectionHealth: input.projection.projectionHealth ?? null, - blocker: input.projection.blocker ?? null, - timing, - startedAt, - lastEventAt: finishedAt, - finishedAt, - durationMs, - eventSource: "workbench-local", - updatedAt: finishedAt - } as NonNullable; - const timingPatch = messageTimingPatchFromProjection(timing); - markMessage(input.traceId, { - status: "failed", - text: input.message, - error: input.error, - projection: input.projection, - projectionStatus: input.projection.projectionStatus ?? null, - projectionHealth: input.projection.projectionHealth ?? null, - blocker: input.projection.blocker ?? null, - runnerTrace, - traceAutoLifecycle: "terminal", - ...timingPatch - }); - rememberTraceAuthority(runnerTrace); - reduceServerState({ - type: "turn.status", - turn: { traceId: input.traceId, status: "failed", running: false, terminal: true, sessionId: input.sessionId, threadId: input.threadId, updatedAt: finishedAt, projection: input.projection, loadedAt: finishedAt } - }); - const existing = sessions.value.find((session) => session.sessionId === input.sessionId) ?? null; - const projectedMessages = serverState.value.messagesBySessionId[input.sessionId] ?? messages.value.filter((message) => firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId) === input.sessionId); - rememberSessionList(mergeSessionIntoList(sessions.value, { - ...(existing ?? {}), - sessionId: input.sessionId, - threadId: firstNonEmptyString(input.threadId, existing?.threadId), - status: "failed", - updatedAt: finishedAt, - lastTraceId: input.traceId, - messageCount: Math.max(existing?.messageCount ?? 0, projectedMessages.length), - messages: projectedMessages - } as WorkbenchSessionRecord)); - publishWorkbenchProjectionSignal(input.sessionId, input.traceId, "submit-admission-failed"); - scheduleSessionListRefresh(input.sessionId, runtimePolicy.sessionListTerminalRefreshDelayMs); + updateActiveMessages((source) => source.map((message) => message.traceId === input.traceId && message.role === "agent" + ? projectRejectedWorkbenchAdmission(message, { ...input, finishedAt }) + : message)); + reduceServerState({ type: "turn.forget", traceId: input.traceId }); } function projectOptimisticRunningTurn(input: { sessionId: string; threadId: string | null; traceId: string; userText: string }): void { diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 56538008..bd2922d2 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -86,12 +86,21 @@ export interface ApiError { message: string; userMessage?: string | null; code?: string | number | null; + failureKind?: string | null; reason?: string | null; + recoveryAction?: string | null; + failureIdentity?: string | null; + primaryPresentation?: "assistant-message" | string | null; + admissionState?: string | null; retryable?: boolean | null; layer?: string | null; category?: string | null; route?: string | null; traceId?: string | null; + upstreamTraceId?: string | null; + runId?: string | null; + commandId?: string | null; + dispatchIntentId?: string | null; requestId?: string | null; source?: "server" | "browser" | string | null; diagnostic?: ErrorDiagnostic | null; diff --git a/web/hwlab-cloud-web/src/utils/workbench-error-runtime.ts b/web/hwlab-cloud-web/src/utils/workbench-error-runtime.ts index 0afae5cd..51bd7616 100644 --- a/web/hwlab-cloud-web/src/utils/workbench-error-runtime.ts +++ b/web/hwlab-cloud-web/src/utils/workbench-error-runtime.ts @@ -174,17 +174,27 @@ export function agentErrorFromProjection(projection: ProjectionDiagnostic): Chat export interface WorkbenchMessageDiagnosticView { visible: boolean; + showMessage: boolean; text: string | null; apiError: ApiError | null; diagnostic: ErrorDiagnostic | null; } export function messageDiagnosticView(message: ChatMessage): WorkbenchMessageDiagnosticView { - if (hasSealedCompletedText(message)) return { visible: false, text: null, apiError: null, diagnostic: null }; + if (hasSealedCompletedText(message)) return { visible: false, showMessage: false, text: null, apiError: null, diagnostic: null }; const text = messageDiagnosticText(message); const diagnostic = messageErrorDiagnostic(message, text); const apiError = messageApiError(message, text, diagnostic); - return { visible: Boolean(text || apiError || diagnostic), text, apiError, diagnostic }; + return { visible: Boolean(text || apiError || diagnostic), showMessage: !messageOwnsPrimaryFailurePresentation(message), text, apiError, diagnostic }; +} + +export function messageOwnsPrimaryFailurePresentation(message: ChatMessage): boolean { + if (message.role !== "agent" || !isTerminalAgentMessage(message) || !visibleMessageText(message)) return false; + const error = recordValue(message.error); + const blocker = recordValue(message.projection?.blocker ?? message.blocker ?? message.runnerTrace?.blocker); + const primaryPresentation = firstNonEmptyString(error?.primaryPresentation, blocker?.primaryPresentation); + const failureIdentity = firstNonEmptyString(error?.failureIdentity, blocker?.failureIdentity, (message as Record).failureIdentity); + return primaryPresentation === "assistant-message" && Boolean(failureIdentity); } function messageDiagnosticText(message: ChatMessage): string | null {