fix(history): 历史运行复盘读库持久化 —— 杜绝过 Redis TTL 后卡「流式中…」

问题:历史任务超 Redis 流 10min TTL 后,SSE 回放在空流上永久阻塞 → 运行页卡「流式中…」、
轨迹/工具/输出全空。

修复:收尾把最终输出 + 执行轨迹持久化到 PG,历史复盘改读库(不依赖 Redis TTL):
- store:Task 加 output/trace 两列;SaveTaskOutput/SaveTaskTrace/GetRunDetail。
  trace 用 type:text(不是 jsonb)——否则提交时空串 "" 入 jsonb 列会 INSERT 失败、整条任务不落库。
  (已 ALTER 既有 trace 列 jsonb→text。)
- gateway:token/exec 录制器在 done 时把累计的输出/轨迹快照落库。
- 新增 GET /tasks/:id/replay 返回持久化的 {output, exec}。
- RunsView:选中历史运行改 runReplay() 读库(秒回、phase 立即 done/error),不再 SSE 回放。
  即便旧任务无持久化数据,也是 done+空态,绝不再卡「流式中…」。

live:新任务落库 output 305 字(含表格) + 轨迹 5 事件,/replay 正确返回;tsc+vite、gateway 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-26 17:52:26 +08:00
parent 594be6a317
commit d2662a1f37
6 changed files with 89 additions and 15 deletions
+8
View File
@@ -519,3 +519,11 @@ export async function taskEval(taskId: string): Promise<EvalResult | null> {
if (!res.ok) return null;
return res.json() as Promise<EvalResult>;
}
// runReplay: 历史运行复盘 —— 读库取持久化的输出 + 轨迹(不依赖 Redis TTL,秒回)。
export async function runReplay(taskId: string): Promise<{ output: string; exec: ExecEvent[] }> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/tasks/${taskId}/replay`, { headers: bearer() }));
if (!res.ok) throw new Error(`replay failed: ${res.status}`);
const d = (await res.json()) as { output?: string; exec?: ExecEvent[] };
return { output: d.output ?? "", exec: d.exec ?? [] };
}
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Activity, FileText, History, Wrench } from "lucide-react";
import { ExecTrace } from "../components/ExecTrace";
import { Markdown } from "../components/Markdown";
import { ChartView } from "../components/ChartView";
import { extractChartBlocks, hasChart } from "../lib/chartspec";
import { deriveNodes, emptyRun, type RunState } from "../lib/run";
import { listRuns, taskEval, streamExec, streamTokens, type RunSummary, type EvalResult } from "../lib/api";
import { listRuns, taskEval, runReplay, type RunSummary, type EvalResult } from "../lib/api";
import { Tabs, Panel, Dot, Badge, EmptyState, cn, type TabDef } from "../ui";
type DetailTab = "trace" | "tools" | "eval";
@@ -32,31 +32,30 @@ export function RunsView({ run }: { run: RunState }) {
const [replay, setReplay] = useState<RunState>(emptyRun);
const [evalRes, setEvalRes] = useState<EvalResult | null>(null);
const [tab, setTab] = useState<DetailTab>("trace");
const closeRef = useRef<(() => void) | null>(null);
useEffect(() => {
let alive = true;
const load = () => listRuns(40).then((r) => alive && setRuns(r)).catch(() => {});
load();
const id = setInterval(load, 5000);
return () => { alive = false; clearInterval(id); closeRef.current?.(); };
return () => { alive = false; clearInterval(id); };
}, []);
// 选中历史运行:读库取持久化的输出 + 轨迹复盘(秒回、不依赖 Redis TTL)。
const selectRun = useCallback((taskId: string) => {
closeRef.current?.();
if (taskId === run.taskId) { setSel(null); setEvalRes(null); return; }
setSel(taskId);
setReplay({ phase: "streaming", taskId, output: "", events: [], exec: [] });
setReplay({ phase: "submitting", taskId, output: "", events: [], exec: [] });
setEvalRes(null);
const closeExec = streamExec(taskId, (ev) => setReplay((r) => ({ ...r, exec: [...r.exec, ev] })), () => setReplay((r) => ({ ...r, phase: "done" })), () => {});
const closeTok = streamTokens(taskId, (tok) => setReplay((r) => ({ ...r, output: r.output + tok })), () => {}, () => {});
closeRef.current = () => { closeExec(); closeTok(); };
runReplay(taskId)
.then((d) => setReplay({ phase: "done", taskId, output: d.output, events: [], exec: d.exec }))
.catch(() => setReplay({ phase: "error", taskId, output: "", events: [], exec: [], error: "复盘数据不可用" }));
taskEval(taskId).then(setEvalRes).catch(() => {});
}, [run.taskId]);
// 新实时运行开始 → 切回「当前运行」+ 回到轨迹标签。
useEffect(() => {
if (run.taskId) { closeRef.current?.(); setSel(null); setEvalRes(null); setTab("trace"); }
if (run.taskId) { setSel(null); setEvalRes(null); setTab("trace"); }
}, [run.taskId]);
// 当前实时运行的评测:完成后拉一次。
@@ -99,7 +98,7 @@ export function RunsView({ run }: { run: RunState }) {
</div>
<div className="min-h-0 flex-1 overflow-auto p-1.5">
{liveActive && (
<button onClick={() => { closeRef.current?.(); setSel(null); setEvalRes(null); }}
<button onClick={() => { setSel(null); setEvalRes(null); }}
className={cn("mb-1 flex w-full items-center gap-2 rounded-md px-2 py-2 text-left", sel === null ? "bg-ink-800 ring-1 ring-brand/40" : "hover:bg-ink-850")}>
<Dot tone={run.phase === "streaming" ? "running" : run.phase === "done" ? "success" : "neutral"} pulse={run.phase === "streaming"} />
<span className="flex-1 truncate text-xs text-slate-200"></span>