From 49d19d9b59d3ea85c41eca24e25f548d762a3370 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 29 Jun 2026 14:50:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20=E5=88=B7=E6=96=B0/=E9=87=8D?= =?UTF-8?q?=E5=BC=80=E5=90=8E=E6=81=A2=E5=A4=8D=E5=9C=A8=E9=80=94=E5=BE=85?= =?UTF-8?q?=E5=AE=A1=E4=BB=BB=E5=8A=A1=20=E2=80=94=E2=80=94=20HITL=20?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E6=9D=A1=E5=8F=AF=E8=B7=A8=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E9=87=8D=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HITL 持久化中断后审批可跨重启、等数小时,但前端 ApprovalBar 只绑定内存里的 live run:用户一刷新页面/重开 app,run 清空 → 审批条消失 → 那个 waiting 任务 再也点不到批准(历史页只读回放、无审批入口)。这是 durable 模型下的真实 UX 洞。 - App: 抽出 attachRun(状态轮询 + exec 流 + token 流),新发起与恢复共用。 - 登录后探测 waiting 任务并 attachRun 挂回 → 全局审批条重现、可批准/拒绝, 续跑 token 经重订阅的流回显(网关从 Redis 回放 exec 补回审批摘要)。 - 坑:StrictMode(dev)双调用 effect,原 cancelled 守卫会把首次 async 的恢复误吞 (restoredRef 已保证只跑一次,App 是根组件不卸载)→ 去掉 cancelled。 真实 UI 验证(preview 驱动 :5173):提交 HITL 任务→waiting→刷新页面→审批条 自动重现→点批准→从 checkpoint 续跑→done,真实出稿 + 评测 1.00。tsc + vitest 绿。 Co-Authored-By: Claude Opus 4.8 --- sundynix-desktop/frontend/src/App.tsx | 100 ++++++++++++++++---------- 1 file changed, 63 insertions(+), 37 deletions(-) diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index 91d84b3..b15d966 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -14,7 +14,7 @@ import { Placeholder } from "./views/Placeholder"; import { CommandPalette, type Command } from "./components/CommandPalette"; import { UpdateBanner } from "./components/UpdateBanner"; import { Login } from "./views/Login"; -import { submitTask, streamTokens, streamExec, taskStatus, authMe, logout, type Identity, type AuthUser } from "./lib/api"; +import { submitTask, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, type Identity, type AuthUser } from "./lib/api"; import type { TaskDsl } from "./lib/dsl"; import { emptyRun, type RunState } from "./lib/run"; import { ToastProvider } from "./ui"; @@ -101,6 +101,42 @@ export default function App() { ]; }, []); + // attachRun:给一个 task 挂上「状态轮询 + 执行轨迹流 + token 流」。新发起与恢复待审任务共用, + // 故页面刷新/重开后能重新挂回在途任务(HITL 审批可能等数小时,必须可恢复,否则待审任务点不到批准)。 + const attachRun = useCallback((taskId: string, t0: number) => { + let first = true; + // 轮询后端任务状态:可靠捕获 waiting(审批中断)—— exec 实时事件会抢跑,状态落 PG 不会丢。 + const terminal = new Set(["done", "failed", "timeout", "rejected"]); + pollRef.current = window.setInterval(async () => { + try { + const s = await taskStatus(taskId); + setRun((r) => (r.taskId === taskId ? { ...r, lifecycle: s.status, detail: s.detail } : r)); + if (terminal.has(s.status)) stopPoll(); + } catch { + /* 忽略瞬时失败,下个 tick 再试 */ + } + }, 1500); + // 执行轨迹:与 token 流并行订阅;网关从 Redis 回放历史事件(含 await),故恢复时也能补回审批摘要。 + execCloseRef.current = streamExec( + taskId, + (ev) => setRun((r) => (r.taskId === taskId ? { ...r, exec: [...r.exec, ev] } : r)), + () => {}, + () => {}, + ); + closeRef.current = streamTokens( + taskId, + (tok) => + setRun((r) => { + if (r.taskId !== taskId) return r; + const ev = first ? [...r.events, { t: Date.now() - t0, label: "首 token" }] : r.events; + first = false; + return { ...r, output: r.output + tok, events: ev }; + }), + () => setRun((r) => (r.taskId === taskId ? { ...r, phase: "done", events: [...r.events, { t: Date.now() - t0, label: "完成" }] } : r)), + () => setRun((r) => (r.taskId === taskId ? { ...r, phase: "error", error: "连接中断" } : r)), + ); + }, []); + const onRun = useCallback( async (dsl: TaskDsl) => { closeRef.current?.(); @@ -111,54 +147,44 @@ export default function App() { setView("runs"); // 发起即跳「运行 · 观测」,实时看轨迹/输出(观测统一收敛在此页) try { const taskId = await submitTask(dsl, identity); - let first = true; setRun((r) => ({ ...r, phase: "streaming", taskId, events: [...r.events, { t: Date.now() - t0, label: `已发布 ${taskId}` }], })); - // 轮询后端任务状态:可靠捕获 waiting(审批中断)—— exec 实时事件会抢跑,状态落 PG 不会丢。 - const terminal = new Set(["done", "failed", "timeout", "rejected"]); - pollRef.current = window.setInterval(async () => { - try { - const s = await taskStatus(taskId); - setRun((r) => (r.taskId === taskId ? { ...r, lifecycle: s.status, detail: s.detail } : r)); - if (terminal.has(s.status)) stopPoll(); - } catch { - /* 忽略瞬时失败,下个 tick 再试 */ - } - }, 1500); - // 执行轨迹(运行·观测):与 token 流并行订阅,逐节点点亮。 - execCloseRef.current = streamExec( - taskId, - (ev) => setRun((r) => ({ ...r, exec: [...r.exec, ev] })), - () => {}, - () => {}, - ); - closeRef.current = streamTokens( - taskId, - (tok) => - setRun((r) => { - const ev = first ? [...r.events, { t: Date.now() - t0, label: "首 token" }] : r.events; - first = false; - return { ...r, output: r.output + tok, events: ev }; - }), - () => - setRun((r) => ({ - ...r, - phase: "done", - events: [...r.events, { t: Date.now() - t0, label: "完成" }], - })), - () => setRun((r) => ({ ...r, phase: "error", error: "连接中断" })), - ); + attachRun(taskId, t0); } catch (e) { setRun((r) => ({ ...r, phase: "error", error: (e as Error).message })); } }, - [identity], + [identity, attachRun], ); + // 恢复在途待审任务:登录后若存在 waiting 任务且当前无 live run,挂回它 → 全局审批条重现, + // 用户刷新页面/重开 app 也能继续批准(HITL 持久化中断后审批可跨重启、可等数小时)。 + const restoredRef = useRef(false); + useEffect(() => { + if (!user || restoredRef.current) return; + restoredRef.current = true; // 只恢复一次。App 是根组件不会真卸载,故不用 cancelled 守卫 + (async () => { // (StrictMode 双调用 effect 时,cancelled 会把首次 async 的恢复误吞)。 + try { + const runs = await listRuns(20); + const waiting = runs.find((r) => r.status === "waiting"); + if (!waiting) return; + const t0 = Date.now(); + setRun((r) => + r.taskId + ? r // 已有 live run,不覆盖 + : { phase: "streaming", taskId: waiting.task_id, output: "", events: [{ t: 0, label: "恢复待审任务" }], exec: [], lifecycle: "waiting" }, + ); + attachRun(waiting.task_id, t0); + } catch { + /* 列表拉取失败则跳过恢复,不影响正常使用 */ + } + })(); + }, [user, attachRun]); + // 鉴权门:校验中显示占位;未登录显示登录页;登录后进入主应用。 if (authLoading) { return
加载中…
;