feat(suspend): 会话正在产出回复时延迟挂起而非切断 - #787
Conversation
Review 结论:🔴 需要改动(1 个 blocking + 2 个待处理项)排队主机制(commit 1)思路是对的,实现也扎实——正在产出的会话记 Blocking:commit 2 的 flush 产物会被 daemon 确定性丢弃时序是闭合的,不是概率窗口:
所以这不是「正向场景尚未观测」,而是当前接线下正向场景无法成功。现有 建议修复时补一条真行为测试:挂 fake worker → 调 待处理项 1:cold-refork race(本 PR 新引入的窗口)对比 master 的旧 待处理项 2:
|
`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>
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>
`pendingSuspendReason` 原来只在 runPendingSuspendIfSettled 内部清除,而该函数 只被 screen_update / screenshot_uploaded 两个 checkpoint 调用 —— 会话安静下来 之后就不再有 checkpoint。于是这些路径会把标志留下: - 排队后 worker 崩溃退出; - 排队后由 `/cd`、读隔离切换、idle sweeper 等直接调 suspendWorker 的路径先挂起。 标志活到下一代 worker,下一代第一次 idle 时被平白挂起一次。既有的 `ownsGeneration` 谓词挡不住 —— 那时传进来的正是新 worker 自己的闭包,检查会 通过。函数内部原有的「worker 已消失就丢标志」分支同样救不了:它自己就长在那个 不会再运行的 checkpoint 里。 改法是把 claim 绑定到排队时的 worker generation,并把消费点挂到生命周期事件上: - 新增 `pendingSuspendGeneration`,在 IPC 路由排队时记录当前 generation; - 新增 `clearPendingSuspendClaim()`,在 **suspendWorker 成功**与 **worker exit** 两处统一消费 —— 只要那一代停止运行,目标态即达成,无论由哪条路径达成; - runPendingSuspendIfSettled 增加 generation 门控作兜底:claim 属于更早的 generation 时就地消费,绝不挂起它从未指向过的 worker。 暂时性拒绝(routing transfer / 不可挂起的 backend)仍然保留 claim 及其 generation,语义不变。 验证: - npx tsc --noEmit 通过 - test/deferred-suspend.test.ts 17 passed(新增 4 条)、test/ipc-suspend-route.test.ts 8 passed - 注入回归验证:去掉 suspendWorker 里的消费点、或去掉 generation 门控, 对应用例分别失败;恢复后全绿 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b512ac3 to
4f6a5e5
Compare
|
感谢 review,三条实质意见都成立,已按「拆分」方向重做。分支已 force-push( 已做的处理1. blocking —— commit 2 已从本 PR 移除
我独立核实了这条: 那组测试之所以 5/5 绿还发现不了,是因为它只对 worker 侧做源码顺序断言,完全没跨过 daemon 的接收边界。这个教训我记下了。 要让 flush 真正落地,需要在那道 fence 上开一个「正在 suspend 的这一个 worker」的定向例外 —— 那道 fence 护着 cards / tokens / readiness / durable turn state,值得单独一个 PR 连同 daemon 侧行为测试一起做,不该挂在这里拖住主机制。 2. 待处理项 1(cold-refork race)—— 随 commit 2 一并消失那个窗口正是 flush 把 3. 待处理项 2(
|
改了什么
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 的窄竞态,正常路径撞不上,稳定复现需故障注入。已决定不做故障注入,按现状如实标注。