Skip to content

feat(suspend): 会话正在产出回复时延迟挂起而非切断 - #787

Open
xu4wang wants to merge 3 commits into
deepcoldy:masterfrom
xu4wang:feat/deferred-suspend
Open

feat(suspend): 会话正在产出回复时延迟挂起而非切断#787
xu4wang wants to merge 3 commits into
deepcoldy:masterfrom
xu4wang:feat/deferred-suspend

Conversation

@xu4wang

@xu4wang xu4wang commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

改了什么

botmux suspend 在会话正在产出回复时会直接杀掉 worker,把那一轮回复丢掉。本 PR 把它改成:正在忙的会话先排队,等它自己结束后自动挂起——语义从「立即挂起」变成「最终挂起」,不漏任何会话。

三个 commit,互相独立、可分别回滚:

  1. feat(suspend) —— 排队 + 兑现机制(daemon 侧)
  2. fix(worker) —— 挂起前 flush transcript 桥里已就绪的回复(worker 侧)
  3. 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

机制

排队:路由收到请求时若 lastScreenStatusworking/analyzing(且 backend 可挂起),不杀 worker,在 DaemonSession 上记 pendingSuspendReason,返回 { ok: true, suspended: false, reason: 'deferred' }

兑现screen_update / screenshot_uploaded 把状态写成 idle/limited 后触发 runPendingSuspendIfSettled

三个不显然但必要的正确性细节:

  • 兑现必须 queueMicrotask 推迟一拍。 suspendWorker 是同步的且会清空 ds.workerds.lastScreenStatus,而同一个 handler 在赋值之后还要读这两个字段做回合收尾——recordUsageForDaemonSessionfinishTurnReactions(✋→✅)都门控在 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 驱动、触发挂起的 idle screen_update 由屏幕分析器驱动,两个独立生产者没有顺序保证。挂起那一刻回复可能还躺在 bridge 队列里,而 case 'suspend' 的自身拆解会把它毁掉两次:stopBridgeWatcher()clearPending()process.exit(0) 又丢掉 process.send 只排队还没写出的 IPC。改为先 drain 两条桥(CLI 还活着时 transcript 才完整),再追一条 suspend_readysendAndFlush 做写屏障(process.send 是 FIFO,回调触发即前面的 final_output 已落到管道上)。

这是尽最大努力而非保证:等待有界 500ms,超时被选中时屏障未成立、仍会丢。不能改成无限等——daemon 在发出 suspend 时就 arm 了 2s SIGTERM backstop(WORKER_SIGTERM_BACKSTOP_MS),无限等根本不是无限等,只会把预算耗光、让 destroySessioncleanup 来不及跑,backing session 和 CLI 留着不释放,挂起要回收的内存一点没回收。

影响面

  • 只改 suspend 路由idle-worker-sweeperhost_overload_sweep 本来就有 busy 守卫,未改动。
  • 非 busy 会话的路由行为逐行不变:排队条件写成 busy && isSuspendableBackendType(...)合取式而非独立前置守卫。写成前置 return 会改掉非 busy 路径——isSuspendableBackendType(undefined) 返回 false,而 test/dashboard-ipc.test.ts 里那条 mock 掉 suspendWorker 的既有用例 fixture 没有 initConfig。合取式下不可挂起的 backend 照旧穿透到 suspendWorker 的 409。
  • worker 侧 flush 对所有 CLI 生效(两条桥:Claude 走 bridgeQueue,codex/grok/traex/pi/hermes/mtr 走 codexBridgeQueue),并顺带修好 idle-worker-sweeper / host_overload_sweep 两条既有挂起路径的同一个洞。
  • 新增 suspend_ready 消息 daemon 侧无 handler:它是写屏障不是命令;worker-pool 的消息 switch 没有穷尽性 default,会被自然忽略。
  • 不引入新配置项或环境变量。不做持久化(daemon 重启丢失排队状态,下一个 suspend all 周期重新排队)。

测试验证

新增 26 条用例(3 个文件)

$ pnpm vitest run --project unit test/deferred-suspend.test.ts \
    test/ipc-suspend-route.test.ts test/worker-suspend-output-flush.test.ts

 ✓  unit  test/worker-suspend-output-flush.test.ts (5 tests)
 ✓  unit  test/deferred-suspend.test.ts (13 tests)
 ✓  unit  test/ipc-suspend-route.test.ts (8 tests)

 Test Files  3 passed (3)
      Tests  26 passed (26)
  • 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。文件头已如实注明这是接线测试而非行为测试及其局限。

全量回归(前后对照)

$ pnpm vitest run --project unit
 Test Files  6 failed | 822 passed | 4 skipped (832)
      Tests  14 failed | 13277 passed | 37 skipped (13328)

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 命中:

$ botmux suspend all
⏳ 已排队(正在回复,完成后自动挂起): cdaf7a59  docs/design/deferred-suspend.md

完成:挂起 0 个,排队 1 个,跳过 515 个。

随后 daemon 日志(同一毫秒的三行,时序完整):

[cdaf7a59] Worker + CLI suspended (manual_suspend); session stays active, cold-resumes ...
[cdaf7a59] Deferred suspend fulfilled (manual_suspend) after turn completed
[worker:cdaf7a59] Suspend requested
§7 步骤 结果
2. 忙时 suspend → 输出 ⏳ 已排队 ✅ 见上
3. 回合结束 → Deferred suspend fulfilled ✅ 日志原文
4. 变 dormant → 下条消息冷启动 --resume ✅ worker PID 已更换,会话上下文完整续回
5. 关键回归:那一轮回复完整送达、未被截断 ✅ 旧代码会在此处杀 worker 丢掉整轮回复

补充验证:受控计时 + transcript 驱动的投递路径

上面那次是被 suspend all 撞上的。随后又做了两组受控验证。

(a) 延迟本身是真的,不是只贴了个标签。 在会话正在产出时发起挂起,然后持续观察 worker:

T0 = 03:14:09  发起挂起 → ⏳ 已排队
03:14:29 +10s  PID=22044 存活   03:15:04 +40s  PID=22044 存活
03:14:41 +20s  PID=22044 存活   03:15:15 +50s  PID=22044 存活
03:14:52 +30s  PID=22044 存活   03:15:27 +60s  PID=22044 存活
03:15:49 +100s PID=22044 存活
03:16:25.618   Deferred suspend fulfilled  ← 回合结束才兑现,延迟 2 分 16 秒

PID 自始至终未变(同一进程,未被杀也未被替换),且排队窗口内 daemon 日志中没有任何
Worker + CLI suspended。同时验了三条分支:排队期间重复请求 → 幂等仍报 ⏳ 已排队
对已 dormant 的会话 → 本就无存活 CLI(目标态已达成)未被误判为 deferred(正是单测
钉的那条不变式);不存在的 id → 正常报错。

(b) 真正重要的一次:transcript 驱动的 final_output 路径。

先踩到一个坑并因此改进了测试设计——第一次注入任务后日志显示:

Bridge fallback suppressed for turn trg_4050 (model called botmux send within window)

只要模型调了 botmux send,transcript 兜底路径根本不会触发。这解释了为什么此前所有
验证都碰不到这条路。于是改为明确要求被测会话本轮不调用 botmux send,让 transcript
兜底投递,再在其产出过程中挂起:

03:24:06  status=working → 立即发起挂起 → ⏳ 已排队: 1dc937ba
03:24:06 ~ 03:24:28      status 持续 working(23 秒,worker 未被杀)
03:24:27.478  Bridge final_output forwarded (turn 1b7dbc70, 462 chars, kind=bridge, attempt 1)
03:24:28.622  Worker + CLI suspended (manual_suspend)
03:24:28.622  Deferred suspend fulfilled (manual_suspend) after turn completed
03:24:29      status=dormant

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-run20/516 个会话报成 ? 未知(daemon 状态读不到),而它们的真实结果是确定可知的「跳过」——那是 cli_listener_status / cli_listener_run 两个伪 app id 下的 Message Listener Preview 会话,压根没有对应的在线 daemon,真实循环在发请求之前就会因同一条件跳过。原实现把「daemon 不在线」和「daemon 在线但 /api/sessions 读失败」混成同一个 undefined,于是把本来能预告准的结果一并降级成了「未知」——这和瞎猜一样是失职,只是失职的方向相反。

改为三态 Lookuprow / no_daemon / unreadable),并补上真实循环里排在 daemon 查找之前、此前漏掉的 !larkAppId && online.length > 1 那道跳过。

同机复验,改前 → 改后:

改前: 20 个 ? 未知(daemon 状态读不到,无法预告)  + 结尾 warning
改后: 22 个 · 将跳过(daemon 不在线: cli_listener_status / cli_listener_run)

现在 518 个目标零「未知」,结尾 warning 也不再出现:
 495 · 将跳过(本就无存活 CLI)
  22 · 将跳过(daemon 不在线: cli_listener_*)
   1 · 将排队(正在回复,完成后自动挂起)

与真实 suspend all 的实际结果(495 无存活 CLI + 20 daemon 不在线 + 1 排队)逐类吻合

注:commit 3 靠 live 复验,没有配套单测——cmdSuspend() 是直接读 session store、拿全局 daemon 列表并打 console.log 的 CLI 入口,为它补单测需要先把这段分类逻辑抽成纯函数。分类函数本身(predictSuspend)已经是纯函数、具备可测形状,但本 PR 未做这一步重构。

已知残留(已在设计文档记录)

  • worker 侧 drain 用的是 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 之前补一条分支即可。
  • live 验证共取得 3 个 deferral 样本suspend all 撞上一次、受控计时一次、transcript 投递路径一次),覆盖了 working 状态、幂等重复请求、已 dormant 会话、无效 id 四种情形。但 limited 状态的兑现、以及 adopt / pty 两条拒绝分支仍只有单测覆盖、无 live 样本——它们需要被限流的会话和 pty backend 会话,本次没有现成样本。
  • commit 2 的正向场景无 live 证据(详见上文「验证边界」一节):它兜的是 idle 边沿 final_output 尚未 drain 的窄竞态,正常路径撞不上,稳定复现需故障注入。已决定不做故障注入,按现状如实标注。

xu4wang and others added 2 commits August 8, 2026 01:46
`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>
@xu4wang
xu4wang requested a review from deepcoldy as a code owner August 7, 2026 18:48
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant