feat(suspend): 会话正在产出回复时延迟挂起而非切断 - #787
Open
xu4wang wants to merge 3 commits into
Open
Conversation
`botmux suspend` 此前没有任何 busy 检查,会话正在生成回复时照杀不误, 用户那条消息的回复就此丢失。改为:正在产出(working/analyzing)的会话 先在 DaemonSession 上记 pendingSuspendReason 并返回 reason:'deferred', 等 screen_update / screenshot_uploaded 转入 idle/limited 时再兑现。 语义从「立即挂起」变成「最终挂起」,不漏会话。 - core/types.ts:新增 pendingSuspendReason(仅存内存,daemon 重启即丢, 下一个 suspend all 周期会重新排队) - worker-pool.ts:runPendingSuspendIfSettled + 两处状态 checkpoint - dashboard-ipc-server.ts:suspend 路由的排队分支 - cli.ts:排队计数(不设兑现上限,可见性就是安全阀)+ dry-run 按路由 分支预告 三个正确性要点详见 docs/design/deferred-suspend.md §5.2: ① 兑现必须 queueMicrotask 推迟一拍——suspendWorker 会清空 worker 与 lastScreenStatus,同步调用会让同一 handler 后面的回合收尾(usage 记账、✋→✅ 反应、状态转移 hook、末尾卡片)整段踩空; ② 必须传 generation 判定——screenshot_uploaded 没有 ownership 守卫, 陈旧的 idle 会让微任务挂掉替换上来的新 worker; ③ 只在 suspendWorker 成功时清标志,被 routing transfer 拒绝时注册 deferUntilSessionTransferSettled 重试(安静会话不再发 screen_update, 等不到下一个 checkpoint)。 影响面:只改 suspend 路由;idle-worker-sweeper 与 host_overload_sweep 本来就有 busy 守卫、未改动。排队条件写成合取式而非前置守卫,非 busy 会话的路由行为逐行不变。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
final_output 由 transcript 驱动(bridge 的 fs.watch + 1s poller),而触发 挂起的 idle screen_update 由屏幕分析器驱动——两个互相独立的生产者,没有 顺序保证。挂起那一刻这一轮的回复可能还躺在 bridge 队列里,而 case 'suspend' 的自身拆解会把它毁掉两次:stopBridgeWatcher() 调 clearPending(), process.exit(0) 又丢掉 process.send 只排队、还没写出的 IPC。 在 teardown 之前先 drain 两条桥(CLI 还活着时 transcript 才完整),再追一条 suspend_ready 走 sendAndFlush 做写屏障——process.send 是 FIFO,它的回调触发 就意味着前面的 final_output 已经落到管道上。daemon 侧不需要 handler:它是 写屏障不是命令。 尽最大努力而非保证:等待有界 500ms,超时被选中时屏障未成立、队列里没写出去 的消息仍会丢。不能改成无限等——daemon 在发出 suspend 时就 arm 了 2s SIGTERM backstop,无限等根本不是无限等,只会把预算耗在这里、让 destroySession 与 cleanup 来不及跑,backing session 和 CLI 留着不释放,挂起要回收的内存一点 没回收。 这个洞现在就存在:idle-worker-sweeper 与 host_overload_sweep 同样按 lastScreenStatus === 'idle' 挂起,只是跑在定时器上、有几秒余量遮着。本改动 对这两条既有路径同样生效。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
live 验证暴露:本机 516 个目标里有 20 个被预告成「未知」,而它们的真实结果 是确定可知的「跳过」——那是 cli_listener_status / cli_listener_run 两个伪 app id 下的 Message Listener Preview 会话,压根没有对应的在线 daemon。 真实循环在发请求之前就会因 findDaemon() 为空而跳过,所以这是**确定结果** 而非未知。原实现把「daemon 不在线」和「daemon 在线但 /api/sessions 读失败」 混成同一个 undefined,把可确定的结果一并降级成了「未知」——把本来能预告准 的报成未知,和瞎猜一样是失职。 改为三态 Lookup(row / no_daemon / unreadable): - no_daemon → 「将跳过(daemon 不在线: <appId>)」,计入 skipped - unreadable → 才是「未知」,并只在这种情况下打结尾 warning - row 存在但查无此行 → 仍是「未知」(判不出走哪条分支) 同时补上真实循环里排在 daemon 查找之前的那道 `!larkAppId && online.length > 1` 跳过,dry-run 此前漏了。 单测和两轮定向 review 都没发现这个缺陷——它只在真实多 bot 环境里显形。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
改了什么
botmux suspend在会话正在产出回复时会直接杀掉 worker,把那一轮回复丢掉。本 PR 把它改成:正在忙的会话先排队,等它自己结束后自动挂起——语义从「立即挂起」变成「最终挂起」,不漏任何会话。三个 commit,互相独立、可分别回滚:
feat(suspend)—— 排队 + 兑现机制(daemon 侧)fix(worker)—— 挂起前 flush transcript 桥里已就绪的回复(worker 侧)fix(cli)——--dry-run区分「daemon 不在线」与「读不到」(live 验证暴露,见下)为什么
POST /api/sessions/:sessionId/suspend此前只有四道守卫(session_not_active/session_transferring/adopt/backend_not_suspendable),没有任何 busy 检查。对比同文件的POST /api/host-overload/sweep就有if (ds.lastScreenStatus !== 'idle') continue;——这个概念在代码库里已经存在,只是没用在 suspend 路由上。在按凭证轮换周期调用
suspend all的部署里,这意味着每个周期都会打断若干个正在生成的回复。31 小时探针采样显示并发 busy 会话数p99=4 / max=5。延迟兑现对凭证轮换而言严格优于立即切断:正在跑的那一轮本来就持有旧凭证,切断它并不能让那一轮用上新凭证,只是白白丢掉一次回复;两种做法对「该会话何时开始用新凭证」的结果相同(都是下一轮)。因此直接改默认,不加开关。
完整设计(含决策取舍、边界情况表、残留窗口)见新增的
docs/design/deferred-suspend.md。机制
排队:路由收到请求时若
lastScreenStatus为working/analyzing(且 backend 可挂起),不杀 worker,在DaemonSession上记pendingSuspendReason,返回{ ok: true, suspended: false, reason: 'deferred' }。兑现:
screen_update/screenshot_uploaded把状态写成idle/limited后触发runPendingSuspendIfSettled。三个不显然但必要的正确性细节:
兑现必须
queueMicrotask推迟一拍。suspendWorker是同步的且会清空ds.worker与ds.lastScreenStatus,而同一个 handler 在赋值之后还要读这两个字段做回合收尾——recordUsageForDaemonSession与finishTurnReactions(✋→✅)都门控在lastScreenStatus === 'idle' || 'limited'上,同步挂起会让它们整段跳过;emitSessionStateTransitionHook会把新状态报成undefined;末尾buildStreamingCard也拿到undefined。同步调用等于为了不切断回复、反手切掉了这一轮的收尾记账。(用的是该文件既有惯用法:同一段里queueMicrotask(cb.enforceLiveSessionCap)的注释正是「Defer until this screen_update has finished using process state」。)必须带 generation 判定。
screenshot_uploaded这个 case 没有ownsLifecycleMutation()守卫(screen_update第一行就有),它今天就无门槛地写lastScreenStatus。把「写状态」升级成「杀 worker」之后:旧 worker 被 refork 替换 → 它退出前排队的screenshot_uploaded(idle)晚到 → 覆写成 idle → 微任务看到新 worker 还活着 → 把刚起来的新 worker 挂了。新 worker 若正在产出,就是原样复现本 PR 要消灭的截断 bug。因此陈旧 checkpoint 保留标志直接返回,只有当前 generation 有资格消费它。只在
suspendWorker成功时清标志,且 transfer 拒绝后要显式重试。 传入的是纯 generation 判定ownsWorkerSession而非ownsLifecycleMutation(后者把「不在 transfer 中」也折了进去):routing transfer 是暂时拒绝,该由suspendWorker自己那道守卫判。而会话安静下来后屏幕分析器就不再发screen_update(只在changed || status 变化时发),所以光保留标志可能等不到下一个 checkpoint——拒绝后用已导出的deferUntilSessionTransferSettled注册重试。worker 侧 flush(第二个 commit):
final_output由 transcript 驱动、触发挂起的 idlescreen_update由屏幕分析器驱动,两个独立生产者没有顺序保证。挂起那一刻回复可能还躺在 bridge 队列里,而case 'suspend'的自身拆解会把它毁掉两次:stopBridgeWatcher()调clearPending(),process.exit(0)又丢掉process.send只排队还没写出的 IPC。改为先 drain 两条桥(CLI 还活着时 transcript 才完整),再追一条suspend_ready走sendAndFlush做写屏障(process.send是 FIFO,回调触发即前面的final_output已落到管道上)。这是尽最大努力而非保证:等待有界 500ms,超时被选中时屏障未成立、仍会丢。不能改成无限等——daemon 在发出 suspend 时就 arm 了 2s SIGTERM backstop(
WORKER_SIGTERM_BACKSTOP_MS),无限等根本不是无限等,只会把预算耗光、让destroySession与cleanup来不及跑,backing session 和 CLI 留着不释放,挂起要回收的内存一点没回收。影响面
suspend路由。idle-worker-sweeper与host_overload_sweep本来就有 busy 守卫,未改动。busy && isSuspendableBackendType(...)的合取式而非独立前置守卫。写成前置return会改掉非 busy 路径——isSuspendableBackendType(undefined)返回false,而test/dashboard-ipc.test.ts里那条 mock 掉suspendWorker的既有用例 fixture 没有initConfig。合取式下不可挂起的 backend 照旧穿透到suspendWorker的 409。bridgeQueue,codex/grok/traex/pi/hermes/mtr 走codexBridgeQueue),并顺带修好idle-worker-sweeper/host_overload_sweep两条既有挂起路径的同一个洞。suspend_ready消息 daemon 侧无 handler:它是写屏障不是命令;worker-pool的消息 switch 没有穷尽性default,会被自然忽略。suspend all周期重新排队)。测试验证
新增 26 条用例(3 个文件)
test/deferred-suspend.test.ts—— 兑现半边。仿照同文件既有的__testOnly_deliverFinalOutput导出__testOnly_runPendingSuspendIfSettled做真行为断言(mock session-store / dashboard-events / logger,写法照抄test/worker-suspend.test.ts):working/analyzing 空操作且标志必须留着、idle/limited 兑现、worker 已 killed 只清标志、不重复挂起、reason 真的被透传(断言 mock 的 logger 收到该 reason)、ownsGeneration真/假两条、suspendWorker拒绝时保留标志、transfer 结算重试的接线。test/ipc-suspend-route.test.ts—— 排队半边,起真 IPC server +vi.spyOn(写法照抄test/ipc-close-route.test.ts):deferred 返回值且没有调用suspendWorker、idle/limited 仍立即挂起、状态undefined不排队、幂等分支仍返回no_live_worker不被误判为 deferred、backend_not_suspendable/adopt_suspend_unsupported两道守卫都排在排队之前。test/worker-suspend-output-flush.test.ts—— worker 侧接线。worker.ts 的 IPC handler 模块级副作用太重、单测里无法独立驱动,按仓库既有惯例(见test/worker-pipe-initial-screen-order.test.ts)用源码断言钉住顺序与关键参数:flush 排在stopBridgeWatcher/destroySession之前且带await、两条桥都 drain 且 codex 那条精确匹配{ signalIdle: false }、写屏障位置在两个 drain 之后、等待有界且预算< 2000ms、两个 drain 各自独立 try/catch。文件头已如实注明这是接线测试而非行为测试及其局限。全量回归(前后对照)
14 条失败已用
git stash拉出干净基线逐条对照确认与本改动无关:其中 13 条(capabilities/command-handler/exact-chat-grant-cli/session-picker-responsive/session-ready-cli)在 stash 掉全部改动后照样失败;余下 1 条cost-calculator-cache只在全量并行下失败,单独跑49 passed,是负载相关 flake 且与本改动文件无交集。pnpm build干净通过。开发过程中曾引入一条真回归(把
backend_not_suspendable写成独立前置守卫,打挂test/dashboard-ipc.test.ts的既有用例),已按上文「影响面」改成合取式修正,现该文件 115 条全绿。Live 端到端验证(已完成)
已部署到一台真实多 bot 机器(11 个 pm2 进程)跑完
docs/design/deferred-suspend.md§7 全部五步。验证对象就是执行验证的那个会话自己——它在生成回复的过程中被botmux suspend all命中:随后 daemon 日志(同一毫秒的三行,时序完整):
⏳ 已排队Deferred suspend fulfilled--resume补充验证:受控计时 + transcript 驱动的投递路径
上面那次是被
suspend all撞上的。随后又做了两组受控验证。(a) 延迟本身是真的,不是只贴了个标签。 在会话正在产出时发起挂起,然后持续观察 worker:
PID 自始至终未变(同一进程,未被杀也未被替换),且排队窗口内 daemon 日志中没有任何
Worker + CLI suspended。同时验了三条分支:排队期间重复请求 → 幂等仍报⏳ 已排队;对已 dormant 的会话 →
本就无存活 CLI(目标态已达成),未被误判为 deferred(正是单测钉的那条不变式);不存在的 id → 正常报错。
(b) 真正重要的一次:transcript 驱动的
final_output路径。先踩到一个坑并因此改进了测试设计——第一次注入任务后日志显示:
即只要模型调了
botmux send,transcript 兜底路径根本不会触发。这解释了为什么此前所有验证都碰不到这条路。于是改为明确要求被测会话本轮不调用
botmux send,让 transcript兜底投递,再在其产出过程中挂起:
kind=bridge即 transcript 驱动的final_output,462 字符完整投递。而挂起请求发生在03:24:06 —— 旧代码会在那一刻杀掉 worker,而这条回复要 21 秒后才存在,会被整条丢掉。
这是本 PR 核心承诺在最相关路径上的直接证据。
commit 2(worker 侧 flush)的验证边界 —— 不夸大
确认被执行:
Suspend requested说明进了case 'suspend',flush 排在其中所有 teardown之前;全机日志里
Suspend (bridge|codex bridge) drain failed计数为 0,两条 drain 从未抛错、也未阻塞挂起。
但正向场景至今未被观测到。 看上面 (b) 的时序:
final_output在:27.478就已转发,worker 到
:28.611才收到 suspend —— 回复比挂起早 1.1 秒,flush 执行时队列里依然没有待发内容。flush 要兜的是更窄的竞态(idle 边沿那一刻 final_output 还没 drain 出来),
正常路径撞不上,稳定复现需要人为制造 drain 延迟(故障注入)。
所以 commit 2 现有证据是「执行了、不出错、不阻塞」,而不是「已证明救回过回复」。
它是给那条窄缝兜底的防御性改动,且独立可回滚(删掉它排队机制照常工作)。
Live 验证暴露的缺陷(commit 3)
单测和两轮定向 review 都没发现,因为它只在真实多 bot 环境里显形:
--dry-run把 20/516 个会话报成? 未知(daemon 状态读不到),而它们的真实结果是确定可知的「跳过」——那是cli_listener_status/cli_listener_run两个伪 app id 下的 Message Listener Preview 会话,压根没有对应的在线 daemon,真实循环在发请求之前就会因同一条件跳过。原实现把「daemon 不在线」和「daemon 在线但/api/sessions读失败」混成同一个undefined,于是把本来能预告准的结果一并降级成了「未知」——这和瞎猜一样是失职,只是失职的方向相反。改为三态
Lookup(row/no_daemon/unreadable),并补上真实循环里排在 daemon 查找之前、此前漏掉的!larkAppId && online.length > 1那道跳过。同机复验,改前 → 改后:
与真实
suspend all的实际结果(495 无存活 CLI + 20 daemon 不在线 + 1 排队)逐类吻合。注:commit 3 靠 live 复验,没有配套单测——
cmdSuspend()是直接读 session store、拿全局 daemon 列表并打console.log的 CLI 入口,为它补单测需要先把这段分类逻辑抽成纯函数。分类函数本身(predictSuspend)已经是纯函数、具备可测形状,但本 PR 未做这一步重构。已知残留(已在设计文档记录)
drainEmittable({ terminalBoundary: true }),若这一轮的 terminal 行在 drain 那一刻还没落到 transcript 里仍会漏——窗口从秒级压到微秒级,但未归零。彻底归零需要让挂起等一个显式的 turn-terminal 信号,属另一个设计。screenshot_uploaded陈旧消息仍会先无守卫地覆盖lastScreenStatus(既有行为)。微任务那条路已由 generation 判定堵死,但还剩一个更窄的窗口:旧 worker 写入 idle 后、新 worker 下次状态更新前,此时调 suspend 路由会看到 idle 从而立即挂起新 worker 而非 deferred。彻底修法是给该 case 整体加 generation 守卫,会改动它对currentImageKey/ usage 的既有行为,故未纳入本 PR。--dry-run复刻不了路由的第一道isSessionTransferring守卫(/api/sessions的行不暴露 transfer 状态),正在 transfer 的会话会被预告成「排队/挂起」而实际返回session_transferring。transfer 是短暂窗口,为一个 dry-run 预告给SessionRow加字段不成比例;已在代码注释与设计文档边界表标明,日后行里若有该状态,在 adopt 之前补一条分支即可。suspend all撞上一次、受控计时一次、transcript 投递路径一次),覆盖了working状态、幂等重复请求、已 dormant 会话、无效 id 四种情形。但limited状态的兑现、以及 adopt / pty 两条拒绝分支仍只有单测覆盖、无 live 样本——它们需要被限流的会话和 pty backend 会话,本次没有现成样本。final_output尚未 drain 的窄竞态,正常路径撞不上,稳定复现需故障注入。已决定不做故障注入,按现状如实标注。