Merge pull request #2511 from pikasTech/fix/2510-duplicate-dispatch-failure
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
fix(workbench): 收敛 AgentRun 未接纳失败投影
This commit is contained in:
@@ -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。
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<ChatMessage["projection"]>;
|
||||
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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ function acknowledgeVisibleMessages(): void {
|
||||
<button type="button" class="dialog-close" aria-label="关闭" @click="detailMessageId = null">x</button>
|
||||
</header>
|
||||
<CodeAgentStatusSummary :message="detailMessage" />
|
||||
<ApiErrorDiagnostic v-if="messageHasDiagnostic(detailMessage)" class="message-detail-diagnostic" title="诊断" :error="diagnosticFor(detailMessage).text" :api-error="diagnosticFor(detailMessage).apiError" :diagnostic="diagnosticFor(detailMessage).diagnostic" compact />
|
||||
<ApiErrorDiagnostic v-if="messageHasDiagnostic(detailMessage)" class="message-detail-diagnostic" title="诊断" :error="diagnosticFor(detailMessage).text" :api-error="diagnosticFor(detailMessage).apiError" :diagnostic="diagnosticFor(detailMessage).diagnostic" :show-message="diagnosticFor(detailMessage).showMessage" compact />
|
||||
<MessageTraceDebugPanel :message="detailMessage" />
|
||||
<MessageActions
|
||||
:message="detailMessage"
|
||||
|
||||
@@ -148,6 +148,6 @@ function traceStorageKey(message: ChatMessage): string {
|
||||
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="message.runnerTrace" :auto-expanded="autoExpanded" :storage-key="traceStorageKey(message)" final-response-placement="outer" :outer-final-response-text="visibleText" />
|
||||
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="思考中..." compact />
|
||||
<MessageMarkdown v-if="visibleText" class="message-text" :source="visibleText" />
|
||||
<ApiErrorDiagnostic v-if="diagnostic.visible" class="message-diagnostic projection-diagnostic" :error="diagnostic.text" :api-error="diagnostic.apiError" :diagnostic="diagnostic.diagnostic" compact />
|
||||
<ApiErrorDiagnostic v-if="diagnostic.visible" class="message-diagnostic projection-diagnostic" :error="diagnostic.text" :api-error="diagnostic.apiError" :diagnostic="diagnostic.diagnostic" :show-message="diagnostic.showMessage" compact />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
@@ -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<ChatMessage["error"]>;
|
||||
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;
|
||||
}
|
||||
@@ -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 }));
|
||||
});
|
||||
|
||||
@@ -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<ChatMessage["runnerTrace"]>;
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>).failureIdentity);
|
||||
return primaryPresentation === "assistant-message" && Boolean(failureIdentity);
|
||||
}
|
||||
|
||||
function messageDiagnosticText(message: ChatMessage): string | null {
|
||||
|
||||
Reference in New Issue
Block a user