fix(desktop): 刷新/重开后恢复在途待审任务 —— HITL 审批条可跨会话重现

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 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-29 14:50:47 +08:00
parent 0378a770ca
commit 49d19d9b59
+63 -37
View File
@@ -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 <div className="flex h-screen w-screen items-center justify-center bg-ink-950 text-sm text-slate-500"></div>;