fix: surface semantic retry progress in workbench
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success

This commit is contained in:
root
2026-07-20 06:24:08 +02:00
parent ab17051d4c
commit 32e239f610
6 changed files with 327 additions and 3 deletions
@@ -134,6 +134,51 @@ describe("TraceTimeline sequence authority", () => {
wrapper.unmount();
});
it("renders semantic infrastructure retry as a readable row", async () => {
const wrapper = mount(TraceTimeline, {
props: {
autoExpanded: true,
trace: {
traceId: "trc_retry_timeline",
eventSource: "hwlab-kafka-sse",
status: "running",
eventCount: 1,
events: [{
sourceEventId: "evt_retry_timeline",
runId: "run_retry",
sourceSeq: 5,
source: "agentrun.kafka",
type: "backend",
status: "retrying",
label: "agentrun:backend:runner-startup-retry-scheduled",
failureDomain: "infrastructure",
failureComponent: "runner-image-pull",
failureCode: "runner-image-registry-unreachable",
summary: "Runner 镜像仓库暂时不可达",
retryPhase: "retryScheduled",
retryAttempt: 2,
retryMaxAttempts: 5,
retryBackoffMs: 8_000,
nextRetryAt: "2026-07-20T10:00:09.000Z",
createdAt: "2026-07-20T10:00:01.000Z"
}]
}
}
});
await nextTick();
expect(wrapper.text()).toContain("基础设施故障");
const row = wrapper.findAll('[data-testid="trace-render-row"]').find((item) => item.text().includes("基础设施故障"));
expect(row).toBeDefined();
if (!row) throw new Error("semantic retry row was not rendered");
expect(row.attributes("data-event-status")).toBe("warn");
expect(row.text()).toContain("基础设施故障");
expect(row.text()).toContain("第 2/5 次重试已安排");
expect(row.text()).toContain("Runner 镜像仓库暂时不可达");
expect(row.text()).toContain("runner-image-registry-unreachable");
wrapper.unmount();
});
it("keeps a tool row stable while projecting visual and accessibility state", async () => {
const wrapper = mount(TraceTimeline, {
props: {
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import WorkbenchMessageCard from "./WorkbenchMessageCard.vue";
describe("WorkbenchMessageCard semantic retry visibility", () => {
it("shows an infrastructure retry banner without opening Trace details", () => {
const wrapper = mount(WorkbenchMessageCard, {
props: {
detailEnabled: false,
message: {
id: "msg_retry_agent",
messageId: "msg_retry_agent",
role: "agent",
title: "Code Agent",
text: "",
status: "running",
traceId: "trc_retry_banner",
sessionId: "ses_retry_banner",
createdAt: "2026-07-20T10:00:00.000Z",
runnerTrace: {
traceId: "trc_retry_banner",
eventSource: "hwlab-kafka-sse",
status: "running",
eventCount: 1,
events: [{
sourceEventId: "evt_retry_banner",
sourceSeq: 5,
type: "backend",
status: "retrying",
failureDomain: "infrastructure",
failureCode: "runner-image-registry-unreachable",
summary: "Runner 镜像仓库暂时不可达",
retryPhase: "retryScheduled",
retryAttempt: 2,
retryMaxAttempts: 5,
nextRetryAt: new Date(Date.now() + 12_000).toISOString(),
createdAt: new Date().toISOString()
}]
}
}
},
global: {
stubs: {
TraceTimeline: true,
LoadingState: true,
MessageMarkdown: true,
ApiErrorDiagnostic: true,
StatusBadge: true
}
}
});
const banner = wrapper.get(".semantic-failure-banner");
expect(banner.text()).toContain("基础设施故障:Runner 镜像仓库暂时不可达");
expect(banner.text()).toContain("第 2/5 次重试已安排");
expect(banner.text()).toMatch(/ \d+ /u);
expect(banner.text()).toContain("runner-image-registry-unreachable");
wrapper.unmount();
});
});
@@ -36,6 +36,7 @@ const diagnostic = computed(() => messageDiagnosticView(props.message));
const visibleText = computed(() => visibleMessageText(props.message));
const durationMeta = computed(() => messageDurationMeta(props.message));
const activityMeta = computed(() => messageActivityMeta(props.message));
const semanticFailure = computed(() => semanticFailureView(props.message, nowMs.value));
const autoExpanded = computed(() => props.traceAutoExpanded ?? traceLifecycleExpanded(props.message, traceTimelinePolicy.value));
function isRunningMessage(message: ChatMessage): boolean {
@@ -121,6 +122,51 @@ function formatDuration(ms: number): string {
function traceStorageKey(message: ChatMessage): string {
return `${props.storageKeyPrefix}.${message.traceId ?? message.runnerTrace?.traceId ?? message.id}`;
}
function semanticFailureView(message: ChatMessage, currentTimeMs: number): { title: string; detail: string; tone: string; code: string | null } | null {
const events = Array.isArray(message.runnerTrace?.events) ? message.runnerTrace.events : [];
const event = [...events].reverse().find((item) => typeof item.failureDomain === "string" && typeof item.retryPhase === "string");
if (!event) return null;
const phase = String(event.retryPhase).toLowerCase().replace(/[^a-z]/gu, "");
const domain = event.failureDomain === "upstream" ? "上游故障" : "基础设施故障";
const summary = firstText(event.summary, event.message, event.failureCode, event.code, "执行依赖暂时不可用");
const attempt = finiteInteger(event.retryAttempt ?? event.attempt);
const maxAttempts = finiteInteger(event.retryMaxAttempts ?? event.maxAttempts);
const progress = attempt !== null && maxAttempts !== null ? `${attempt}/${maxAttempts}` : "本次";
let detail = "正在判定有限重试";
let tone = "warning";
if (phase.includes("scheduled")) {
const nextRetryMs = timestampMs(event.nextRetryAt);
const remainingSeconds = nextRetryMs === null ? null : Math.max(0, Math.ceil((nextRetryMs - currentTimeMs) / 1_000));
detail = `${progress}重试已安排${remainingSeconds === null ? "" : `,预计 ${remainingSeconds} 秒后开始`}`;
} else if (phase.includes("started")) {
detail = `${progress}重试已开始`;
} else if (phase.includes("recovered")) {
detail = "故障已恢复,继续执行";
tone = "recovered";
} else if (phase.includes("exhausted")) {
detail = "有限重试已耗尽,执行已失败";
tone = "failed";
}
return {
title: `${domain}${summary}`,
detail,
tone,
code: firstText(event.failureCode, event.code)
};
}
function firstText(...values: unknown[]): string {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value.trim();
}
return "";
}
function finiteInteger(value: unknown): number | null {
const number = Number(value);
return Number.isSafeInteger(number) && number >= 0 ? number : null;
}
</script>
<template>
@@ -145,9 +191,44 @@ function traceStorageKey(message: ChatMessage): string {
<button v-if="detailEnabled && message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="emit('details', message)">!</button>
</div>
</header>
<section v-if="semanticFailure" class="semantic-failure-banner" :data-tone="semanticFailure.tone" role="status" aria-live="polite">
<strong>{{ semanticFailure.title }}</strong>
<span>{{ semanticFailure.detail }}</span>
<code v-if="semanticFailure.code">{{ semanticFailure.code }}</code>
</section>
<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" :show-message="diagnostic.showMessage" compact />
</article>
</template>
<style scoped>
.semantic-failure-banner {
display: grid;
gap: 0.2rem;
margin: 0.55rem 0;
border-left: 3px solid #d97706;
border-radius: 0.35rem;
background: #fffbeb;
color: #92400e;
padding: 0.55rem 0.7rem;
}
.semantic-failure-banner[data-tone="recovered"] {
border-left-color: #15803d;
background: #f0fdf4;
color: #166534;
}
.semantic-failure-banner[data-tone="failed"] {
border-left-color: #b91c1c;
background: #fef2f2;
color: #991b1b;
}
.semantic-failure-banner span,
.semantic-failure-banner code {
font-size: 0.82rem;
}
</style>