feat(圆桌): 查看运行中作业改为接管回前端续跑 + 后台挂起续跑方案文档

- onView 运行中/待澄清作业改走 takeOverRunningJob:cancelJob 停后端 →
  handleLoadDraft 恢复作业真实线程/协调名/对话 → 解除只读闸门 →
  resumeLiveTakeover 在作业线程上由前端继续实时流式编排
- hydrateFromDraft 同步写 threadIdsRef/coordinatorNameRef,使接管同帧可读真实线程
- 新增 docs/multi-agent-后台挂起续跑实现方案.md(仅方案,待实现)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-13 18:49:19 +08:00
1 parent 25dbeb7a9a
commit 391409b6c0
3 files changed
+259 -31

No files matched your search

@@ -341,6 +341,13 @@ export interface UseStep2OrchestrationReturn {
hasConsensus: boolean;
lastLeaderContent: string;
}) => void;
/**
* 「后台挂起接管」(2026-06-13):点「查看」一个运行中的后台作业并取消它、
* `hydrateFromDraft` 恢复作业真实线程 + 完整对话后调用 —— 在那些线程上**由前端
* 继续实时流式编排**(leader 自由派活续轮,不从头重跑 DAG)。返回是否成功接管
* (无线程时返回 false,调用方据此回退到只读查看)。
*/
resumeLiveTakeover: () => boolean;
/** 「新建任务」清空 Step 2 数据(threadIds / dialogues / 共识等)。 */
resetStep2State: () => void;
/** 当前沙箱预览绑定的席位 thread id。 */
@@ -2634,6 +2641,11 @@ export function useStep2Orchestration(
}
setThreadIds(snapshot.threadIds);
setCoordinatorName(snapshot.coordinatorName);
// 同步把线程/协调名写进 ref(平时由 [threadIds]/[coordinatorName] 的 effect 异步回写,
// 滞后一拍)。后台挂起「接管续跑」会在 hydrate 之后**同帧**调 resumeLiveTakeover,
// 需要 ref 立刻可读到作业的真实线程,否则读到旧值导致接管失败。
threadIdsRef.current = snapshot.threadIds;
coordinatorNameRef.current = snapshot.coordinatorName;
setStep2RoundtableDialogues(snapshot.step2RoundtableDialogues);
setLastLeaderContent(snapshot.lastLeaderContent);
setHasConsensus(snapshot.hasConsensus);
@@ -2671,6 +2683,32 @@ export function useStep2Orchestration(
[],
);
// 「后台挂起接管」:取消后端作业 + hydrateFromDraft(恢复作业真实线程/协调名/对话)后,
// 由前端在这些线程上**继续实时流式编排**。续跑走 leader 自由派活循环(把 dagPlanExecuted
// 置 true 避免从头重跑整张 DAG),并清掉残留的取消/编排锁让新一轮立刻起跑——
// 与 handleInjectToRoundtable 末尾「直接启动新一轮编排」同构。
const resumeLiveTakeover = useCallback((): boolean => {
if (!threadIdsRef.current || !coordinatorNameRef.current) {
triggerToast("⚠️ 接管失败:未找到研讨线程,已切换为只读查看");
return false;
}
dagPlanExecutedRef.current = true;
orchestrationCancelRef.current = true;
abortAllInFlight();
isOrchestratingRef.current = false;
setIsOrchestrating(false);
setPendingClarification(null);
setClarificationDraft("");
setIsRoundTableRunning(true);
void runActiveOrchestration(
"用户已从后台挂起接管本次研讨,请基于现有进展继续上一轮的协调与派活工作;若研讨已可收口则直接综合输出结论。",
threadIdsRef.current,
coordinatorNameRef.current,
);
triggerToast("🎮 已接管至前端,继续实时研讨");
return true;
}, [abortAllInFlight, runActiveOrchestration, triggerToast]);
const resetStep2State = useCallback(() => {
orchestrationCancelRef.current = true;
abortAllInFlight();
@@ -2749,6 +2787,7 @@ export function useStep2Orchestration(
handleSaveAgentAndRecalculate,
hydrateFromDraft,
applyLiveJobFrame,
resumeLiveTakeover,
resetStep2State,
activeArtifactThreadId,
seatStubMessages,
@@ -103,12 +103,11 @@ import {
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import {
cancelJob,
getJobByDraft,
startJob,
startJobKeepalive,
streamJob,
} from "@/roundtable-planning/api/roundtable-jobs";
import { mapJobToLiveStep2 } from "@/roundtable-planning/lib/job-live";
import { getChain } from "@/roundtable-planning/api/chains";
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
import { listAgents } from "@/core/agents";
@@ -1028,40 +1027,32 @@ export default function RoundtablePlanningPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep, drafts.currentDraftId]);
// ── 「查看=恢复流程」(2026-06-13):查看运行中的后台作业 → SSE 实时跟随 ──
// 加载草稿后订阅 streamJob,把作业每帧对话/产物/共识进度灌进第二步画面(像本地
// 编排一样实时长出气泡),终态时重载草稿衔接最终 run + step3,并停止订阅。
// ── 「查看=接管回前端」(2026-06-13):查看运行中的后台作业 → 接管续跑 ──
// 取消后端作业 → 加载作业最近写回草稿的快照(含作业真实线程 + 完整对话)→ 解除
// 「看历史」只读闸门 → 由前端在那些线程上**继续实时流式编排**(leader 自由派活续轮)。
// 这样「后台挂起 → 查看」会把会商真正搬回前端,按钮/暂停/干预/再挂起全部回归,而不是
// 停在只读跟随的后台态。
const jobFollowUnsubRef = useRef<(() => void) | null>(null);
const stopJobFollow = useCallback(() => {
jobFollowUnsubRef.current?.();
jobFollowUnsubRef.current = null;
}, []);
// 「查看运行中作业」:先 await 草稿 hydrate(建好席位/线程基础 + 切第二步),**再**
// 订阅 SSE —— 保证实时帧总是最后权威,不被草稿快照覆盖(竞态根因)。强制停在第二步
// 看研讨流程(作业可能已到 Step3,但用户点查看就是要看实时研讨过程)。
const followRunningJob = useCallback(
const takeOverRunningJob = useCallback(
async (jobId: string, draftId: string) => {
stopJobFollow();
step2.setIsRoundTableRunning(false);
step2.stopOrchestration();
// 点查看前**立刻存一次**当前聊天(若用户在本地还跑着研讨,保住其记录再切去看作业)。
await drafts.saveDraftNow(2).catch(() => null);
// 1. 取消后端作业:避免前后端双跑/抢同一线程(409),线程随后由前端接管续跑。
// executor.cancel 会 task.cancel() 中断在跑的 run,DB 同步置 cancelled。
await cancelJob(jobId).catch(() => null);
// 2. 加载作业最近写回草稿的快照:恢复 threadIds(作业真实线程)+ 协调名 + 完整对话。
// hydrateFromDraft 已同步写好 threadIds/coordinatorName 的 ref,供下一步接管即时读取。
await drafts.handleLoadDraft(draftId);
setCurrentStep(2); // 覆盖 hydrate 可能切到的 furthestStep,停在第二步看流程
jobFollowUnsubRef.current = streamJob(jobId, {
onProgress: (j) => {
step2.applyLiveJobFrame(mapJobToLiveStep2(j));
},
onDone: (j) => {
stopJobFollow();
// 终态:作业已把最终 run + step3 写回草稿 → 重载草稿无缝衔接(含报告)。
if (j.draftId) void drafts.handleLoadDraft(j.draftId);
void drafts.refresh();
},
onError: () => {
/* 断流静默:画面保留最后一帧;用户可重新点「查看」再续 */
},
});
setCurrentStep(2); // 覆盖 hydrate 可能切到的 furthestStep,停在第二步继续研讨
// 3. 关掉「看历史」只读闸门 → 后台挂起/暂停/干预按钮回归,回到前端可交互态。
drafts.loadedFromDraftRef.current = false;
// 4. 前端接管:在作业线程上继续实时流式编排(无线程则回退为只读查看,不再续跑)。
step2.resumeLiveTakeover();
void drafts.refresh();
},
[stopJobFollow, step2, drafts, setCurrentStep],
);
@@ -1306,13 +1297,13 @@ export default function RoundtablePlanningPage() {
if (!o) setChainModalJobId(null);
}}
onView={(job) => {
// 「查看」(2026-06-13 恢复流程语义):done → 加载草稿(含 step3)看报告;
// **运行中/待澄清 → 草稿 hydrate 完成后 SSE 实时跟随**——第二步消息列表像
// 本地编排一样实时长出新气泡/步骤卡,不再是静态聊天记录。
// 「查看」(2026-06-13 接管语义):done → 加载草稿(含 step3)看报告;
// **运行中/待澄清 → 接管回前端**:取消后端作业、加载聊天记录、解除只读,
// 由前端在作业线程上继续实时流式编排(不再是只读跟随后台)。
setChainModalJobId(null);
if (!job.draftId) return;
if (job.status === "running" || job.status === "awaiting_input") {
void followRunningJob(job.id, job.draftId);
void takeOverRunningJob(job.id, job.draftId);
return;
}
void drafts.handleLoadDraft(job.draftId);