fix(roundtable): 优化 Step2 总控派活稳定性与并发编排

限制总控工具白名单、关闭圆桌记忆注入、综合轮 synthesis_mode 与编排 epoch 防双发,并优化广播异步化及 Postgres checkpointer 连接池。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-06-07 21:20:58 +08:00
1 parent 9c9c912657
commit 924c8f2fac
8 files changed
+767 -302

No files matched your search

@@ -203,6 +203,12 @@ export interface StreamMultiAgentRequest {
newMessage: string;
model?: string;
skillStopNames?: string[];
/**
* 仅 leader 用。置 true 表示这是「派活后让总控判断继续/综合」的轮次:后端会把各已交付
* 席位的**完整交付正文**额外注入总控输入,供其写最终方案。派活决策轮(首轮等)传 false/省略,
* 后端只给摘要,使总控上下文精简、响应快。
*/
synthesisMode?: boolean;
signal?: AbortSignal;
/** Fires for every non-status data frame the upstream emits. */
onUpstreamFrame?: (frame: unknown) => void;
@@ -415,6 +421,8 @@ export async function streamMultiAgent(
new_message: req.newMessage,
model: req.model,
skill_stop_names: req.skillStopNames ?? [],
// 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。
synthesis_mode: req.synthesisMode ?? false,
}),
signal: req.signal,
});
@@ -381,6 +381,12 @@ export function useStep2Orchestration(
// ─── Refs ────────────────────────────────────────────────────────
const orchestrationCancelRef = useRef<boolean>(false);
// 编排「世代号」。每次启动一轮编排(runOrchestration / runChainOrchestration)就 +1。
// 旧循环捕获自己的 epoch,在每个取消检查点比对 —— 一旦 epoch 变了(说明已有新循环
// 接管,例如人工干预),旧循环立即退出。这能根治「新循环把 orchestrationCancelRef
// 重置为 false 反而把正在退出的旧循环『反取消』,导致旧循环继续又发一个 leader run」
// 的并发双发 bug:epoch 是单调递增的,新循环无法把它『重置』回旧循环的值。
const orchestrationEpochRef = useRef<number>(0);
const step2InitRef = useRef<boolean>(false);
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
/** 干预研审:恢复会商后待执行的 leader prompt。 */
@@ -993,17 +999,26 @@ export function useStep2Orchestration(
const runOrchestration = useCallback(
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
if (isOrchestratingRef.current) return;
// **同步**抢占编排锁。isOrchestratingRef 平时由 [isOrchestrating] 的 effect 异步
// 回写(见上方),滞后一拍;若只靠它,人工干预里 setIsRoundTableRunning(true) 触发的
// 「恢复/干预」effect 会在 ref 尚未变 true 的同一 tick 内再调一次本函数 —— 两个 leader
// run 并发打到同一总控 thread(rollback 抢占 → 截断的那个旧报 502、存活的那个重复出现)。
// 这里立刻把 ref 置 true,让重入守卫即时生效,堵住二次触发。
isOrchestratingRef.current = true;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
// 认领本轮 epoch。任何更晚启动的循环都会让 epoch 自增 → 本循环 isStale() 立即为真。
const myEpoch = (orchestrationEpochRef.current += 1);
// 单一「该退出了吗」判定:被显式取消,**或**已被更新的循环接管(epoch 变了)。
// 后者专治新循环重置 orchestrationCancelRef=false 把旧循环反取消的并发双发。
const isStale = () =>
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
let nextLeaderTask = seedMessage;
let safetyCounter = 0;
try {
while (
!orchestrationCancelRef.current &&
safetyCounter < MAX_CYCLES
) {
while (!isStale() && safetyCounter < MAX_CYCLES) {
safetyCounter += 1;
// Leader(协调智能体)正在派活,**不应该**让任何 sub agent 显示"研讨中"——
// 协调者本身不在右侧"参与角色"面板里(COMMANDER_SEAT_ID=1 是人类主持人,
@@ -1034,6 +1049,9 @@ export function useStep2Orchestration(
dAgentThreadId: ids,
agentName: coord,
newMessage: nextLeaderTask,
// 第 2 轮起(已有席位交付)才是「综合轮」:让后端注入各席位完整交付供总控收口。
// 首轮纯派活、无交付,传 false 保持上下文精简。
synthesisMode: safetyCounter > 1,
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
@@ -1064,7 +1082,7 @@ export function useStep2Orchestration(
}
activeAbortersRef.current.delete(leaderAborter);
if (orchestrationCancelRef.current) break;
if (isStale()) break;
if (leaderRes.clarification) {
finalizeDialogue(
@@ -1101,7 +1119,7 @@ export function useStep2Orchestration(
// **顺序**派活 —— 不要改回 Promise.all,详见 §5.4。
for (const [subName, task] of leaderRes.dispatched as LeaderDispatch[]) {
if (orchestrationCancelRef.current) break;
if (isStale()) break;
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
if (meta) markSpeakerStreaming(meta.speakerId);
const perSeatModel = meta
@@ -1149,7 +1167,7 @@ export function useStep2Orchestration(
onUpstreamFrame: (frame) =>
handleUpstreamFrame(frame, seatThreadId),
});
if (orchestrationCancelRef.current) {
if (isStale()) {
finalizeDialogue(
subBubbleId,
specRes.content || "(已停止)",
@@ -1181,7 +1199,7 @@ export function useStep2Orchestration(
if (aborted) break;
}
if (orchestrationCancelRef.current) break;
if (isStale()) break;
setConsensusPercentage((prev) =>
Math.min(95, parseFloat((prev + 8).toFixed(1))),
@@ -1229,8 +1247,15 @@ export function useStep2Orchestration(
const runChainOrchestration = useCallback(
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
if (isOrchestratingRef.current) return;
// 同步抢占编排锁(原因同 runOrchestration):堵住人工干预时 isRoundTableRunning
// toggle effect 在同一 tick 内的二次触发,避免并发双 leader run。
isOrchestratingRef.current = true;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
// epoch 接管:新循环启动即自增,旧链条循环据此退出(详见 runOrchestration / 声明处注释)。
const myEpoch = (orchestrationEpochRef.current += 1);
const isStale = () =>
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
const chainSeats = selectedAgentsRef.current;
const total = chainSeats.length;
@@ -1239,7 +1264,7 @@ export function useStep2Orchestration(
try {
for (let i = 0; i < total; i += 1) {
if (orchestrationCancelRef.current) {
if (isStale()) {
finishedAllSeats = false;
break;
}
@@ -1295,7 +1320,7 @@ export function useStep2Orchestration(
throw err;
}
activeAbortersRef.current.delete(leaderAborter);
if (orchestrationCancelRef.current) {
if (isStale()) {
finalizeDialogue(leaderBubbleId, leaderRes.content || "(已停止)", "已停止");
finishedAllSeats = false;
break;
@@ -1357,7 +1382,7 @@ export function useStep2Orchestration(
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId),
});
if (orchestrationCancelRef.current) {
if (isStale()) {
finalizeDialogue(subBubbleId, specRes.content || "(已停止)", "已停止");
aborted = true;
} else {
@@ -1384,7 +1409,7 @@ export function useStep2Orchestration(
}
// ── (c) 总控收口:综合全链交付,产出最终结论 ──
if (!orchestrationCancelRef.current && finishedAllSeats && total > 0) {
if (!isStale() && finishedAllSeats && total > 0) {
highlightSpeaker(null);
const closeoutBubbleId = appendStreamingDialogue({
sender: "总控协调",
@@ -1402,6 +1427,8 @@ export function useStep2Orchestration(
agentName: coord,
newMessage:
"各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。不要再派活、不要再向任何席位分配任务。",
// 链条收口就是综合轮:让后端注入各席位完整交付正文。
synthesisMode: true,
model: selectedModelRef.current || undefined,
signal: closeoutAborter.signal,
onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text),
@@ -1604,7 +1631,7 @@ export function useStep2Orchestration(
content: `💡 (人工指令介入) ${userText}`,
confidence: "最高指令",
});
pendingInjectPromptRef.current = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
pendingInjectPromptRef.current = `用户人工干预新指令:${userText}\n\n请优先按这条新指令,通过 agent_orchestration 把活派给相关的真实席位去获取交付;若用户点名了某个席位(如"前端实现"),必须派给清单里对应的那个 agent_name。**不要**自己代答,也**不要**在没有真正看到该席位「完成…交付内容为:…」的情况下臆造交付或直接收口。只有当用户明确要求收口、或相关席位都已真实交付时,才综合输出结论。`;
triggerToast("干预指令已记录,会商已自动恢复执行");
}, [appendDialogue, customAgentInput, triggerToast]);
@@ -1631,7 +1658,7 @@ export function useStep2Orchestration(
confidence: "最高指令",
});
const prompt = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
const prompt = `用户人工干预新指令:${userText}\n\n请优先按这条新指令,通过 agent_orchestration 把活派给相关的真实席位去获取交付;若用户点名了某个席位(如"前端实现"),必须派给清单里对应的那个 agent_name。**不要**自己代答,也**不要**在没有真正看到该席位「完成…交付内容为:…」的情况下臆造交付或直接收口。只有当用户明确要求收口、或相关席位都已真实交付时,才综合输出结论。`;
// 确保处于运行状态(同步更新按钮 UI)
setIsRoundTableRunning(true);