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:
@@ -14,7 +14,7 @@ import { Placeholder } from "./views/Placeholder";
|
|||||||
import { CommandPalette, type Command } from "./components/CommandPalette";
|
import { CommandPalette, type Command } from "./components/CommandPalette";
|
||||||
import { UpdateBanner } from "./components/UpdateBanner";
|
import { UpdateBanner } from "./components/UpdateBanner";
|
||||||
import { Login } from "./views/Login";
|
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 type { TaskDsl } from "./lib/dsl";
|
||||||
import { emptyRun, type RunState } from "./lib/run";
|
import { emptyRun, type RunState } from "./lib/run";
|
||||||
import { ToastProvider } from "./ui";
|
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(
|
const onRun = useCallback(
|
||||||
async (dsl: TaskDsl) => {
|
async (dsl: TaskDsl) => {
|
||||||
closeRef.current?.();
|
closeRef.current?.();
|
||||||
@@ -111,54 +147,44 @@ export default function App() {
|
|||||||
setView("runs"); // 发起即跳「运行 · 观测」,实时看轨迹/输出(观测统一收敛在此页)
|
setView("runs"); // 发起即跳「运行 · 观测」,实时看轨迹/输出(观测统一收敛在此页)
|
||||||
try {
|
try {
|
||||||
const taskId = await submitTask(dsl, identity);
|
const taskId = await submitTask(dsl, identity);
|
||||||
let first = true;
|
|
||||||
setRun((r) => ({
|
setRun((r) => ({
|
||||||
...r,
|
...r,
|
||||||
phase: "streaming",
|
phase: "streaming",
|
||||||
taskId,
|
taskId,
|
||||||
events: [...r.events, { t: Date.now() - t0, label: `已发布 ${taskId}` }],
|
events: [...r.events, { t: Date.now() - t0, label: `已发布 ${taskId}` }],
|
||||||
}));
|
}));
|
||||||
// 轮询后端任务状态:可靠捕获 waiting(审批中断)—— exec 实时事件会抢跑,状态落 PG 不会丢。
|
attachRun(taskId, t0);
|
||||||
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: "连接中断" })),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setRun((r) => ({ ...r, phase: "error", error: (e as Error).message }));
|
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) {
|
if (authLoading) {
|
||||||
return <div className="flex h-screen w-screen items-center justify-center bg-ink-950 text-sm text-slate-500">加载中…</div>;
|
return <div className="flex h-screen w-screen items-center justify-center bg-ink-950 text-sm text-slate-500">加载中…</div>;
|
||||||
|
|||||||
Reference in New Issue
Block a user