Files
Blizzard 158fe094ab fix(hitl): 审批复盘轨迹补全 —— 中断不关 exec 流,resume 事件续录
现象:已完成的审批任务,复盘轨迹停在「已中断等待审批」、审批节点永远转圈,看着
像卡住(实际任务已 done、有输出)。

根因:任务中断时 Handle 的 defer tr.done() 给 exec 流发了 CompleteExec → 网关 exec
录制器关闭。resume 是另一次独立调用,其 exec 事件(审批通过/拒绝、续跑节点)发到
同一主题时录制器已关 → 没录进去。(token 流中断时特意没关,所以输出录到了;exec
流却被关了,不对称。)

- exec.go: execTracer 加 suspended 标志,done() 挂起时不关流。
- orchestrator.go: 中断分支置 tr.suspended=true(与 token 流一致)。
- ExecTrace.tsx: 前端兜底——运行已完成(phase done)时把悬挂的 waiting/running 节点
  收敛为 done,修旧任务(已 done、轨迹缺 resume 事件)的转圈假象。

live 验证:审批任务批准后复盘轨迹完整呈现 approval await→end「批准:放行」→
agent start→推理→end,审批节点不再转圈。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:57:11 +08:00

105 lines
4.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Cpu,
Bookmark,
Wrench,
MessageSquareText,
Sparkles,
ListTree,
FileText,
FileOutput,
CheckCircle2,
XCircle,
Loader2,
Circle,
type LucideIcon,
} from "lucide-react";
import { deriveNodes, type NodeTrace, type RunPhase } from "../lib/run";
import type { ExecEvent } from "../lib/api";
import { cn } from "../ui";
// 各节点类别的图标与配色(与后端 ExecEvent.kind 对应)。
const KIND: Record<string, { icon: LucideIcon; cls: string; name: string }> = {
system: { icon: Cpu, cls: "text-slate-400", name: "系统" },
memory: { icon: Bookmark, cls: "text-brand-400", name: "记忆召回" },
tool: { icon: Wrench, cls: "text-warn", name: "工具调用" },
prompt: { icon: MessageSquareText, cls: "text-sky-300", name: "提示词" },
model: { icon: Sparkles, cls: "text-success", name: "模型推理" },
plan: { icon: ListTree, cls: "text-accent-400", name: "规划" },
section: { icon: FileText, cls: "text-indigo-300", name: "章节" },
render: { icon: FileOutput, cls: "text-danger", name: "渲染" },
};
function meta(kind: string) {
return KIND[kind] ?? { icon: Circle, cls: "text-slate-400", name: kind };
}
function StatusDot({ status }: { status: NodeTrace["status"] }) {
if (status === "running") return <Loader2 className="h-4 w-4 animate-spin text-accent-400" strokeWidth={2.4} />;
if (status === "done") return <CheckCircle2 className="h-4 w-4 text-success" strokeWidth={2.2} />;
if (status === "error") return <XCircle className="h-4 w-4 text-danger" strokeWidth={2.2} />;
if (status === "waiting") return <Loader2 className="h-4 w-4 animate-spin text-amber-400" strokeWidth={2.4} />; // 待审批
return <Circle className="h-4 w-4 text-slate-600" strokeWidth={2} />;
}
// ExecTrace 把执行事件流渲染为竖向轨道:每个节点一颗灯,实时点亮 + 耗时 + 入参/产出。
export function ExecTrace({ events, phase, compact }: { events: ExecEvent[]; phase?: RunPhase; compact?: boolean }) {
const raw = deriveNodes(events);
// 运行整体已完成,但轨迹可能缺了中断后(resume)续录的事件(如审批通过、续跑节点),
// 导致审批节点停在 waiting 转圈。既然已完成,把悬挂的 waiting/running 收敛为 done,不再假装进行中。
const nodes =
phase === "done"
? raw.map((n) => (n.status === "waiting" || n.status === "running" ? { ...n, status: "done" as const } : n))
: raw;
if (nodes.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-1 px-4 py-8 text-center text-xs text-slate-600">
<span></span>
<span className="text-slate-700"> / </span>
</div>
);
}
const total = nodes.reduce((s, n) => s + (n.ms ?? 0), 0);
return (
<div className={compact ? "" : "p-1"}>
{!compact && (
<div className="mb-2 flex items-center gap-3 px-1 text-[11px] text-slate-500">
<span>{nodes.length} </span>
<span>·</span>
<span> {total} ms</span>
{phase === "streaming" && <span className="text-accent-400"> </span>}
{phase === "done" && <span className="text-success"> </span>}
{phase === "error" && <span className="text-danger"> </span>}
</div>
)}
<ol className="relative ml-2 space-y-2 border-l border-line pl-4">
{nodes.map((n) => {
const m = meta(n.kind);
const Icon = m.icon;
return (
<li key={n.node} className="relative">
<span className="absolute -left-[22px] top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-ink-900">
<StatusDot status={n.status} />
</span>
<div className="rounded-md border border-line bg-ink-950/60 px-3 py-2">
<div className="flex items-center gap-2">
<Icon className={cn("h-3.5 w-3.5 shrink-0", m.cls)} strokeWidth={2} />
<span className="text-xs font-medium text-slate-200">{n.label}</span>
<span className={cn("rounded bg-ink-800 px-1.5 py-0.5 text-[9px]", m.cls)}>{m.name}</span>
{n.ms != null && n.ms > 0 && <span className="ml-auto font-mono text-[10px] text-slate-500">{n.ms} ms</span>}
{n.status === "running" && <span className="ml-auto text-[10px] text-accent-400"></span>}
</div>
{n.detail && <p className="mt-1 break-words text-[11px] leading-relaxed text-slate-400">{n.detail}</p>}
{n.notes.map((note, i) => (
<p key={i} className="mt-1 break-words border-l border-line pl-2 text-[11px] text-slate-500">
{note}
</p>
))}
</div>
</li>
);
})}
</ol>
</div>
);
}