10 KiB
后台挂起「在当前进度继续」实现方案
状态:待实现(仅方案,未改代码) 关联:multi-agent-background-run-dev.md、multi-agent-frontend-dev.md、memory
project-roundtable-background目标:点「后台挂起」后,后端作业在前端当前研讨进度上接着跑,而不是新建空线程从头重跑。
1. 现象与根因
现象
在第二步研讨进行中点「后台挂起」,转入后台的作业是从头重新开始执行(重新初始化席位、从原始 intent 重新派活),而不是接着前端已经讨论到的进度往下走。
根因
后端「起作业」路由 start_job 从不设 is_resume(默认 False),而执行器据此新建一套全新线程:
# offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py (_run, 约 215 行)
init = getattr(gateway, "init_threads", None)
if init is not None and not params.is_resume:
thread_ids, coordinator_name = await init() # ← 建全新空线程
加上前端 startBackgroundJob 故意不传 threadIds、用 buildInitialIntent() 作第一轮 leader prompt,于是后台作业 = 空线程 + 原始意图 = 从头重跑。
这是原设计的有意取舍。
startBackgroundJob注释写明:不复用前端线程,因为「那些线程可能有活跃 run,复用会撞 409」。本方案就是要在可控前提下打开「复用前端线程续跑」这条路。
后端其实已支持续跑
澄清续跑路由 resume_job(roundtable_jobs.py 约 293 行)已经在做「在原线程上接着派活」:
executor.start_job(StartParams(
...,
seed_message=_RESUME_PREFIX + answer, # 续跑式提示,而非原始 intent
thread_ids=row.get("thread_ids"), # 复用作业自己的线程
coordinator_name=row.get("coordinator_name") or "roundtable-coordinator",
is_resume=True, # ← 关键:跳过 init_threads,直接复用
...
))
执行器在 is_resume=True 时直接用 params.thread_ids + params.coordinator_name(不调 init_threads),交给 run_orchestration 续跑。所以能力已存在,start_job 只是没走这条路。
2. 设计目标与关键事实
- 「同进度续跑」= 后台作业复用前端当前会话的真实线程(总控 + 各席位),这些线程的 checkpoint 里已存着到目前为止的完整研讨上下文,续跑 = 在原线程上 post 一条「继续」消息让总控接着派活。
- 前端编排是逐轮的:每轮一个
POST /api/multi-agent/run/stream(leader 或某席位),轮与轮之间线程空闲。只有「某一轮在飞时」线程才被占用。 - 后端复用现成线程是已验证路径(
resume_job天天在用),唯一新增的不同点是:这次复用的是「前端会话线程」而非「上一个后台作业的线程」——但线程同属一个后端线程库,复用方式完全一致。
3. 改造点(最小集)
3.1 后端:让 start_job 支持复用线程
文件:offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py
StartJobRequest增加字段:resume: bool = False # true + 携带 threadIds ⇒ 复用这些线程续跑,不新建start_job里把它透传给StartParams:
其余执行器逻辑不动(它在is_resume = bool(body.resume and body.threadIds) executor.start_job(StartParams( ..., thread_ids=body.threadIds, coordinator_name=body.coordinatorName, is_resume=is_resume, # ← 新增 ... ))is_resume=True时已能复用params.thread_ids)。
幂等闸门(同草稿未终态作业直接返回)保持不变;续跑挂起前前端应保证该草稿没有在跑的旧作业。
3.2 前端 API:补 resume 入参
文件:frontend-web/src/roundtable-planning/lib/job-types.ts
StartJobPayload增加resume?: boolean。
文件:frontend-web/src/roundtable-planning/api/roundtable-jobs.ts
toStartBody增加resume: p.resume ?? false。
3.3 前端:startBackgroundJob 改为复用线程续跑
文件:frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx(startBackgroundJob 约 1119 行)
当前端已有现成会话(step2.threadIds 且 step2.coordinatorName 非空,说明研讨已 init)时,改传:
const canContinue = !!(step2.threadIds && step2.coordinatorName);
await startJob({
draftId: draftId ?? undefined,
// 续跑:用「继续」式 seed,而不是 buildInitialIntent()(后者会让总控从头理解任务)
intent: buildInitialIntent(), // 仍传,作为无线程回退时的首轮 prompt
seedMessage: canContinue
? "用户已将本次研讨转入后台,请基于现有进展继续上一轮的协调与派活;若已可收口则直接综合输出结论。"
: undefined,
agents,
model: selectedModel || undefined,
orchestrationMode,
chain: chainMeta,
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
// 关键三件套:复用前端真实线程 + 协调名 + resume 标志
threadIds: canContinue ? step2.threadIds ?? undefined : undefined,
coordinatorName: canContinue ? step2.coordinatorName ?? undefined : undefined,
resume: canContinue,
});
没有现成会话(极早期、线程未建)时
canContinue=false,自动回退到「新建线程 + 原 intent」的旧行为,安全。
3.4 前端:挂起前先停本地编排(避免交出去时线程还在飞)
文件:同上,handleBackgroundSuspend(约 1161 行)
当前顺序是 startBackgroundJob() → resetToStep1()(reset 里才 stopOrchestration)。复用线程时必须先停再交:
const handleBackgroundSuspend = useCallback(async () => {
if (step2.selectedAgents.length === 0) { /* toast 提示后 return */ }
// 1. 先彻底停掉前端在飞的那一轮,尽量让线程在交给后台前变空闲
step2.stopOrchestration();
step2.setIsRoundTableRunning(false);
try {
// 2. 再起后台作业(复用线程续跑)
await startBackgroundJob();
} catch (err) { /* toast 失败后 return */ }
// 3. 最后重置回第一步(后台作业继续在历史记录里跑)
resetToStep1();
triggerToast("🚀 已转入后台,将基于当前进度继续研讨…");
void drafts.refresh();
}, [...]);
4. 关键风险:线程占用(409)与三种处理
复用前端线程的唯一风险:在某一轮在飞时挂起,stopOrchestration() 只断了客户端 SSE,服务端那次 run 可能还在跑一小会儿;后台作业首轮 leader 向同一总控线程 post → 撞 409 / rollback。
| 方案 | 做法 | 取舍 |
|---|---|---|
| A(推荐起步) | 仅前端 stop + 后端 is_resume 复用,接受小概率 409 |
实现最简,真正同进度续跑;轮间挂起 100% 安全,仅「某轮在飞时挂起」有小概率 409 —— 走编排循环现有 rollback/重试容错兜底 |
| B(最稳) | 同 A,但后端在复用线程续跑前,先 cancel 这些线程上可能残留的 run(参考 takeover 里 cancelJob 的 task.cancel() 思路,落到 thread-run 级别)再 post |
几乎无 409;后端要多写「按 thread_id 取消活跃 run」的逻辑 |
| C(不推荐) | 不复用线程,新建空线程,但把「目前为止的研讨」塞进 seed_message 让总控续写 | 零 409,但席位子线程上下文全丢、不是真正同进度、leader prompt 臃肿 |
建议:先上 A,真机验证 409 命中率;若频繁再加 B 的后端线程级 cancel。
5. 适用范围(哪些挂起入口改续跑)
三个触发源(见 autoSuspendRef):
- 手动「后台挂起」按钮(
handleBackgroundSuspend)——改续跑。能在 start 前干净 stop,最适合复用线程。 - 路由卸载自动挂起(切到其它页面,
fire)——可改续跑(能走完整异步链);卸载瞬间 stop 不一定彻底,按 A 方案的容错兜底。 - 刷新/关标签页 keepalive(
beforeunload的beacon→startJobKeepalive)——建议保持新建线程。关页时无法保证前端 run 已停,复用线程 409 风险最高;这里走「新建线程 + 原 intent」的安全回退即可(用户可在历史记录里点「查看」再用我们已实现的 takeover 接管回前端续跑)。
落地顺序建议:先只改 #1,验证稳定后再决定是否推广到 #2。
6. 与已实现「查看=接管回前端」的关系
二者是一对对称操作,复用同一套「线程同属一个后端库、可跨前后端续跑」的事实:
- 后台挂起(本方案):前端 → 后端。前端 stop,把前端线程交给后台
is_resume续跑。 - 查看接管(已实现,见 memory):后端 → 前端。
cancelJob停后台,hydrateFromDraft恢复作业真实线程,resumeLiveTakeover()由前端在那些线程上续跑。
实现本方案时可直接参照 takeover 的线程复用与 is_resume 经验。
7. 测试
后端(make test):
- 新增/扩展
tests/test_roundtable_jobs.py:start_job带resume=true + threadIds⇒StartParams.is_resume=True且不调init_threads、直接用传入线程;resume=false或无threadIds⇒ 走init_threads新建(回归旧行为)。 - 回归:
tests/test_roundtable_inprocess_request.py、tests/test_roundtable_step3_pipeline.py(确认续跑不破坏 Step3 双产物)。
前端(pnpm typecheck)+ 真机:
- 研讨进行到几轮后点「后台挂起」→ 历史记录里点「查看」→ 确认后台是接着已有对话往下派活,而非从第 1 轮重来。
- 轮间挂起 / 某轮在飞时挂起两种时机各验一次,观察是否出现 409(A 方案下应被 rollback 容错吸收)。
8. 落地清单(TL;DR)
- 后端
StartJobRequest.resume字段 +start_job透传is_resume - 前端
StartJobPayload.resume+toStartBody - 前端
startBackgroundJob:有会话时传threadIds/coordinatorName/seedMessage/resume - 前端
handleBackgroundSuspend:调整为 先 stop 再 start 再 reset - 后端测试(resume 复用 vs 新建分支)
- 真机验证「接着进度跑」+ 409 命中率
- (可选)方案 B:后端复用线程前按 thread_id 取消残留 run