From d2662a1f377982b324c477e7b8cde310d24852d0 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Fri, 26 Jun 2026 17:52:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(history):=20=E5=8E=86=E5=8F=B2=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E5=A4=8D=E7=9B=98=E8=AF=BB=E5=BA=93=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=20=E2=80=94=E2=80=94=20=E6=9D=9C=E7=BB=9D=E8=BF=87=20?= =?UTF-8?q?Redis=20TTL=20=E5=90=8E=E5=8D=A1=E3=80=8C=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E4=B8=AD=E2=80=A6=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:历史任务超 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) --- sundynix-desktop/frontend/src/lib/api.ts | 8 ++++ .../frontend/src/views/RunsView.tsx | 21 +++++---- .../internal/handler/task_handler.go | 43 +++++++++++++++++-- sundynix-gateway/internal/router/router.go | 1 + sundynix-gateway/internal/store/models.go | 3 ++ sundynix-gateway/internal/store/pgsql.go | 28 ++++++++++++ 6 files changed, 89 insertions(+), 15 deletions(-) diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index 0441332..db500a6 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -519,3 +519,11 @@ export async function taskEval(taskId: string): Promise { if (!res.ok) return null; return res.json() as Promise; } + +// 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 ?? [] }; +} diff --git a/sundynix-desktop/frontend/src/views/RunsView.tsx b/sundynix-desktop/frontend/src/views/RunsView.tsx index 6a45b8c..7470035 100644 --- a/sundynix-desktop/frontend/src/views/RunsView.tsx +++ b/sundynix-desktop/frontend/src/views/RunsView.tsx @@ -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(emptyRun); const [evalRes, setEvalRes] = useState(null); const [tab, setTab] = useState("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 }) {
{liveActive && ( -