feat(roundtable): 后台作业按用户并发限流 + 草稿持久化优化
后台挂起作业每个 agent 轮次(leader/seat/report/summary/dashboard)开跑前 先取该用户的后台并发额度(默认 2,ROUNDTABLE_BACKGROUND_SLOTS_PER_USER 可调), 跑完释放;同一用户多个后台作业共享额度,避免把单连接 SQLite checkpointer 和 离线模型端点打满拖垮前台实时会商。前台实时路径不经过此额度,不受限。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
da3d38013e
commit
8d1669adad
3 files changed
+204
-20
No files matched your search
@@ -120,6 +120,11 @@ export interface UseDraftPersistenceReturn {
|
||||
* 发起前 / 点查看前各存一次,保住当时的聊天记录不丢。返回 draft id。
|
||||
*/
|
||||
saveDraftNow: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
|
||||
/**
|
||||
* 用**显式传入的 step2 快照**强制存一次(不读 live 状态)。「后台挂起直接跳转」用:
|
||||
* 跳转会清空 step2,调用方在跳转前冻结快照传进来,保证落库的是挂起那一刻的研讨记录。
|
||||
*/
|
||||
saveStep2SnapshotNow: (step2: DraftStep2Snapshot, furthestStep?: 1 | 2 | 3) => Promise<string | null>;
|
||||
/** 强制刷新草稿列表。 */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
@@ -287,6 +292,51 @@ export function useDraftPersistence(
|
||||
[currentStep, enqueueSave, refreshList],
|
||||
);
|
||||
|
||||
/**
|
||||
* 用**显式传入的 step2 快照**强制存一次(不读 live 状态)。
|
||||
*
|
||||
* 「后台挂起改直接跳转」用:点挂起后页面立刻 resetToStep1 会清空 step2,而存稿是
|
||||
* fire-and-forget 在跳转之后才真正落库——若仍走 `saveDraftNow`(其 `getStep2Snapshot`
|
||||
* 是延迟读取)就会存到被重置后的空 step2。调用方在跳转前同步 `getStep2Snapshot()` 冻结
|
||||
* 一份快照传进来,这里只认这份,确保存的是挂起那一刻的研讨记录。
|
||||
*
|
||||
* 有草稿 id → update(保护重命名,不带 title);无 id → 用当前 step1 + 传入 step2 建档。
|
||||
*/
|
||||
const saveStep2SnapshotNow = useCallback(
|
||||
(step2: DraftStep2Snapshot, furthestStep: 1 | 2 | 3 = 2): Promise<string | null> => {
|
||||
const run = async (): Promise<string | null> => {
|
||||
const id = currentDraftIdRef.current;
|
||||
if (id) {
|
||||
const updated = await updateDraft(id, { furthestStep, step2 });
|
||||
if (updated) {
|
||||
currentDraftIdRef.current = updated.id;
|
||||
return updated.id;
|
||||
}
|
||||
// 服务端已不存在 → 落回新建。
|
||||
}
|
||||
const step1 = getStep1Snapshot();
|
||||
if (!hasSavableContent(step1, step2)) return null;
|
||||
const created = await createDraft({
|
||||
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
|
||||
title: deriveDraftName({
|
||||
intentReady: step1.intentReady,
|
||||
interactiveMessages: step1.interactiveMessages,
|
||||
}),
|
||||
furthestStep,
|
||||
step1,
|
||||
step2,
|
||||
});
|
||||
currentDraftIdRef.current = created.id;
|
||||
setCurrentDraftId(created.id);
|
||||
return created.id;
|
||||
};
|
||||
const next = saveQueueRef.current.then(run, run);
|
||||
saveQueueRef.current = next.catch(() => undefined);
|
||||
return next as Promise<string | null>;
|
||||
},
|
||||
[getStep1Snapshot, hasSavableContent, setCurrentDraftId],
|
||||
);
|
||||
|
||||
const handleManualSave = useCallback(() => {
|
||||
const furthest = (currentStep as 1 | 2 | 3) || 1;
|
||||
void (async () => {
|
||||
@@ -535,6 +585,7 @@ export function useDraftPersistence(
|
||||
handleCancelRename,
|
||||
ensureDraft,
|
||||
saveDraftNow,
|
||||
saveStep2SnapshotNow,
|
||||
/** 强制刷新草稿列表(如后台挂起后让「进行中」状态立刻出现)。 */
|
||||
refresh: refreshList,
|
||||
};
|
||||
|
||||
@@ -1143,6 +1143,26 @@ export default function RoundtablePlanningPage() {
|
||||
});
|
||||
}, [resetToStep1]);
|
||||
|
||||
// 「挂起中」本地标记:点「后台挂起」后立刻跳转,建作业是 fire-and-forget——从点击到作业
|
||||
// 真正建好的窗口里,用它让历史记录显示「挂起中」(作业建好后转 queued/running,由后端状态接管)。
|
||||
// resetToStep1 不卸载本页,故该 state 跨「跳转」存活。
|
||||
const [pendingSuspendIds, setPendingSuspendIds] = useState<Set<string>>(() => new Set());
|
||||
const markPendingSuspend = useCallback((draftId: string) => {
|
||||
setPendingSuspendIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(draftId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const clearPendingSuspend = useCallback((draftId: string) => {
|
||||
setPendingSuspendIds((prev) => {
|
||||
if (!prev.has(draftId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(draftId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 「后台挂起」核心(手动按钮 / 离开页面自动挂起共用):幂等保证草稿存在 →
|
||||
// 同草稿已有在跑作业则跳过 → 启动后台作业。后端另有「同草稿未终态作业幂等返回」
|
||||
// 兜底(覆盖跨标签页 / beforeunload 与本函数并发触发),绝不重复建作业。
|
||||
@@ -1191,27 +1211,87 @@ export default function RoundtablePlanningPage() {
|
||||
buildInitialIntent,
|
||||
]);
|
||||
|
||||
// 「后台挂起」按钮:把当前研讨交给后端自动跑完 Step2 研讨 + Step3(总结报告 + 结果绘制)。
|
||||
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。
|
||||
const handleBackgroundSuspend = useCallback(async () => {
|
||||
if (step2.selectedAgents.length === 0) {
|
||||
// 「后台挂起」按钮(2026-06-16 改「直接跳转」):点击后**立刻**跳回第一步并提示,挂起动作
|
||||
// (存稿 → 去重 → 建后台作业)改为 fire-and-forget 在后台完成,不再等接口返回才跳转。
|
||||
// 历史记录在作业真正建好前显示「挂起中」(pendingSuspendIds),建好后由后端状态(queued→
|
||||
// running)接管。⚠️ resetToStep1 会清空 step2 等 live 状态,故挂起入参 + step2 快照必须在
|
||||
// 跳转前同步冻结(getStep2SnapshotWithRuns 返回的是不可变快照对象,reset 替换 state 不影响它)。
|
||||
const handleBackgroundSuspend = useCallback(() => {
|
||||
const selected = step2.selectedAgents;
|
||||
if (selected.length === 0) {
|
||||
triggerToast("⚠️ 请先选择研讨席位再后台挂起");
|
||||
return;
|
||||
}
|
||||
let jobId: string | null = null;
|
||||
try {
|
||||
jobId = await startBackgroundJob();
|
||||
} catch (err) {
|
||||
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
// 注册到全工作区通知器,作业完成时**跨页**弹「达成共识同款」提示。
|
||||
if (jobId) watchJob(jobId);
|
||||
// 重置回第一步(同新建任务);后台作业在历史记录里继续跑、可点开看进度。
|
||||
// —— 跳转前同步冻结所有需要的输入(reset 之后这些 live 状态都会被清空)——
|
||||
const step2Snapshot = getStep2SnapshotWithRuns();
|
||||
const agents = selected.map((a) => ({ agentId: a.agent_id, name: a.name }));
|
||||
const intent = buildInitialIntent();
|
||||
const model = selectedModel || undefined;
|
||||
const seatModeSnap = seatMode;
|
||||
const mode = orchestrationMode;
|
||||
const chainSnap = chainMeta;
|
||||
const planSnap = mode === "dag" ? orchestrationPlan : null;
|
||||
const draftId = drafts.currentDraftId;
|
||||
|
||||
// 立刻:标记「挂起中」+ 跳回第一步 + 提示(不等接口)。
|
||||
if (draftId) markPendingSuspend(draftId);
|
||||
resetToStep1();
|
||||
triggerToast("🚀 已转入后台,任务会自动跑完研讨、总结报告与结果绘制;可在「历史记录」查看进度");
|
||||
void drafts.refresh();
|
||||
}, [step2, drafts, startBackgroundJob, resetToStep1, triggerToast, watchJob]);
|
||||
|
||||
// 后台 fire-and-forget:用冻结快照存稿 → 去重 → 建作业 → 注册完成通知。失败只提示、不挡跳转。
|
||||
void (async () => {
|
||||
let watchId: string | null = null;
|
||||
try {
|
||||
const savedId = (await drafts.saveStep2SnapshotNow(step2Snapshot, 2)) ?? draftId ?? null;
|
||||
// 防重复:该草稿已有未终态作业就不再新建(同 startBackgroundJob 的客户端去重,
|
||||
// 后端另有「同草稿未终态作业幂等返回」兜底)。
|
||||
const existing = savedId ? await getJobByDraft(savedId).catch(() => null) : null;
|
||||
const alreadyRunning =
|
||||
existing &&
|
||||
(existing.status === "running" ||
|
||||
existing.status === "awaiting_input" ||
|
||||
existing.status === "queued");
|
||||
if (alreadyRunning) {
|
||||
watchId = existing?.id ?? null;
|
||||
} else {
|
||||
const job = await startJob({
|
||||
draftId: savedId ?? undefined,
|
||||
intent,
|
||||
agents,
|
||||
model,
|
||||
seatMode: seatModeSnap,
|
||||
orchestrationMode: mode,
|
||||
chain: chainSnap,
|
||||
orchestrationPlan: planSnap,
|
||||
});
|
||||
watchId = job.id;
|
||||
}
|
||||
} catch (err) {
|
||||
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
|
||||
} finally {
|
||||
// 注册到全工作区通知器,作业完成时**跨页**弹「达成共识同款」提示。
|
||||
if (watchId) watchJob(watchId);
|
||||
// 清「挂起中」本地标记:此后历史记录的状态由后端作业(queued→running)权威接管。
|
||||
if (draftId) clearPendingSuspend(draftId);
|
||||
void drafts.refresh();
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
step2,
|
||||
drafts,
|
||||
getStep2SnapshotWithRuns,
|
||||
buildInitialIntent,
|
||||
selectedModel,
|
||||
seatMode,
|
||||
orchestrationMode,
|
||||
orchestrationPlan,
|
||||
chainMeta,
|
||||
resetToStep1,
|
||||
triggerToast,
|
||||
watchJob,
|
||||
markPendingSuspend,
|
||||
clearPendingSuspend,
|
||||
]);
|
||||
|
||||
// ── 自动后台挂起:研讨进行中离开本页面(路由卸载)或刷新 / 关标签页时,任务自动转后台 ──
|
||||
// 最新状态进 ref:卸载 cleanup 与 beforeunload 都是「拍快照时刻」的同步回调,
|
||||
@@ -1427,6 +1507,7 @@ export default function RoundtablePlanningPage() {
|
||||
setIsOpen={drafts.setIsHistoryOpen}
|
||||
drafts={drafts.drafts}
|
||||
currentDraftId={drafts.currentDraftId}
|
||||
pendingSuspendIds={pendingSuspendIds}
|
||||
renamingDraftId={drafts.renamingDraftId}
|
||||
renameDraftValue={drafts.renameDraftValue}
|
||||
setRenameDraftValue={drafts.setRenameDraftValue}
|
||||
@@ -1752,6 +1833,8 @@ interface HistoryDropdownProps {
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
drafts: ReturnType<typeof useDraftPersistence>["drafts"];
|
||||
currentDraftId: string | null;
|
||||
/** 正在「挂起中」(已点后台挂起、作业尚未建好)的草稿 id 集合 —— 历史记录显示「挂起中」。 */
|
||||
pendingSuspendIds: Set<string>;
|
||||
renamingDraftId: string | null;
|
||||
renameDraftValue: string;
|
||||
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
@@ -1774,6 +1857,7 @@ function HistoryDropdown(props: HistoryDropdownProps) {
|
||||
setIsOpen,
|
||||
drafts,
|
||||
currentDraftId,
|
||||
pendingSuspendIds,
|
||||
renamingDraftId,
|
||||
renameDraftValue,
|
||||
setRenameDraftValue,
|
||||
@@ -1844,6 +1928,8 @@ function HistoryDropdown(props: HistoryDropdownProps) {
|
||||
{drafts.map((d) => {
|
||||
const isCurrent = d.id === currentDraftId;
|
||||
const isRenaming = d.id === renamingDraftId;
|
||||
// 「挂起中」:已点后台挂起、作业还没建好(本地标记),或作业刚建好仍排队(queued)。
|
||||
const isSuspending = pendingSuspendIds.has(d.id) || d.status === "queued";
|
||||
// 「进行中 / 待澄清」且有 jobId → 点击打开流程图弹窗,而非加载草稿。
|
||||
const isActiveJob =
|
||||
(d.status === "running" || d.status === "awaiting_input") && !!d.jobId;
|
||||
@@ -1881,7 +1967,12 @@ function HistoryDropdown(props: HistoryDropdownProps) {
|
||||
{d.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 mt-0.5 flex items-center gap-1 font-mono">
|
||||
{d.status === "running" ? (
|
||||
{isSuspending ? (
|
||||
<span className="inline-flex items-center gap-1 text-sky-500 font-semibold">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
挂起中
|
||||
</span>
|
||||
) : d.status === "running" ? (
|
||||
<span className="inline-flex items-center gap-1 text-primary font-semibold">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
进行中
|
||||
|
||||
Reference in new issue
Block a user