fix(riff): 增加两阶段关闭与守护进程关停围栏 - #598
Conversation
deepcoldy
left a comment
There was a problem hiding this comment.
Claude 首次 review(head 09b96ef5)
结论:协议本体质量高,可以进入复审;但有 1 个必须在合码前处理的「上线顺序」硬约束 + 2 个小问题。
审查基线说明:本 PR 基于 #596(已合入 master,merge 6dbaefbb)。GitHub 显示 23 文件是因为 fork 基点早于 #596 合并,真实改动应取
git diff $(git merge-base master pr-598)..pr-598 = 21 文件 / +4910 −136(13 src + 8 test,9 个新文件)。
验证情况(本机实测,非「应该没问题」)
pnpm build✅ /npx tsc --noEmit✅ 干净- 相关 9 个测试文件 185 tests 全绿(riff-shutdown-detach 37 / riff-backend 46 / session-store 44 / riff-explicit-close 8 / worker-riff-retirement-protocol 7 / 其余)
- 自写对抗探针(临时文件,已删除,工作区干净)验证了几条关键性质,都通过:
- abort 确实是并发的:3 个挂死 worker、单个超时 400ms → 总耗时 401ms(串行会是 ~1200ms),没有互相吃预算
- prepare drain 有界:worker 永不回应 → 300ms 超时返回,且正确标记
fence: 'possible'(模糊态必须走 abort 确认) - deadline 已过 →
fence: 'none'且根本没碰 worker,riffShutdownState未被写入(不会留下悬空 fence) - durable owner CAS 敏感:durable pid 与 runtime pid 不一致 → 拒绝
- workerless prepared fence 可被干净 abort 自愈;一旦出现新 worker generation → 保留 fence(fail-closed,符合设计意图)
🔴 P1(阻塞 live 上线,不阻塞代码本身):supervisor 超时没跟上,riff daemon 重启会掉孤儿 worker
本 PR 把 daemon 优雅关停预算从 3s 拉到 28s(DAEMON_SHUTDOWN_MAX_MS,src/core/shutdown-budgets.ts:23),但 supervisor 侧两个值仍是老的:
src/cli.ts:424— pm2kill_timeout: 3500src/cli.ts:2413—deleteAllBotmuxProcesses轮询deadline = Date.now() + 5_000,过期后无条件pm2 delete
而 daemon 内部顺序是:riff drain(daemon.ts:17578)→ commit → stopScheduler()(17689)→ 普通 worker 的 SIGTERM 循环在最后。
时间线(带 riff 会话、worker 迟迟不 ACK prepare):
t=0.0s SIGTERM
t≤12s riff drain 等待(RIFF_SHUTDOWN_DRAIN_TIMEOUT_MS,内含最长 10s 的 create/follow-up HTTP)
t=12s drain 超时 → prepare 失败 → abort wave 最长 11s
t=23s → "Daemon remains online"(拒绝退出)
其间:
t=3.5s pm2 kill_timeout → SIGKILL
t=5.0s botmux restart 轮询到期 → pm2 delete → SIGKILL
daemon 在 3.5~5s 被 SIGKILL,此时普通 worker 连 SIGTERM 都还没收到 → ppid=1 孤儿。正是 cli.ts:421 注释里记录的「841 孤儿 / 65GB」那个场景,注释本身就写着 kill_timeout 必须大于 daemon 关停预算。
重要缓解:#599 已经修好这一层——PM2_DAEMON_KILL_TIMEOUT_MS = 29_000,并加了编译期不变量 PM2_DAEMON_KILL_TIMEOUT_MS > DAEMON_SHUTDOWN_MAX_MS,5s 轮询也替换成新的 fleet-shutdown 机制。
所以这不是设计缺陷,是合码/上线顺序约束。建议二选一:
补充:本机 ~/.botmux/bots.json 有 2 个 bot 配置为 cliId: riff,所以不是纯理论场景(当前恰好没有 active riff 会话,风险窗口取决于何时新建会话)。
非 riff daemon 完全不受影响(riffCandidates 为空 → 不触发 drain,exit grace 仍是 3000ms,实测计算确认)。
🟡 P2:i18n key 缺失,用户会在飞书收到字面量 key
src/core/worker-pool.ts:2298 使用 tr('worker.riff_close_in_progress', ...),但该 key 在 src/i18n/zh.ts 与 en.ts 中都不存在。t() 的兜底是「找不到就返回 key 本身」(src/i18n/index.ts 注释明确写 "so missing keys are loud")。
实测(跑编译产物 dist/i18n/index.js):
MISSING KEY zh => "worker.riff_close_in_progress"
MISSING KEY en => "worker.riff_close_in_progress"
CONTROL 已有 key => "⏏ /adopt的 CLI 会话已断开"
可达性:sendWorkerInput 是主消息投递路径;prepareLiveRiffWorkerClose 在 await worker(最长 23s)之前就设置了 ds.riffCloseState,这段窗口内用户任何一条消息都会走到这个分支。即用户 /close 一个 riff 会话后紧接着发消息,就会收到字面量 worker.riff_close_in_progress。
修法:在 zh.ts / en.ts 各补一条文案即可。
🟡 P3:riff 的 restart 被 worker 静默拒绝,但 daemon 侧 4 个入口仍报成功
src/worker.ts:9920 新增:riff 的 restart IPC 只 log() 然后 break,不回任何消息。但 daemon 侧 4 个发送点都没有 riff 判断:
| 入口 | 位置 | 用户看到 |
|---|---|---|
/restart 命令 |
command-handler.ts:1329 |
回「正在重启…」(cmd.restart.in_progress) |
| Dashboard 重启 | dashboard-ipc-server.ts:546 |
HTTP 200 {ok:true} |
| 飞书卡片按钮 | card-handler.ts:1623 |
重启提示 |
| 崩溃自动重启 | worker-pool.ts:3895 |
日志称正在重启 |
实际什么都没发生 → 静默假成功。
但方向是对的:改动前 restartCliProcess 会调 destroySession()(worker.ts:8244),对 riff 来说等于取消远端任务、销毁沙箱与上下文。所以「拒绝 restart」比原行为安全,这里只是缺一个用户可见的解释。建议在上述入口对 riff 明确回一句「riff 会话不支持重启,请 /close 后新建」,而不是假报成功。
顺带确认:worker 侧同时新增的 riff suspend 拒绝是防御性死代码——suspendWorker(worker-pool.ts:1752)有 isSuspendableBackendType 前置判断(只放行 tmux/herdr/zellij),riff 根本走不到 suspend IPC。无问题。
设计上确认无误的地方(对抗性看过,认为正确)
- 两套协议正确分离:显式
/close会取消远端任务;关停 detach 绝不取消,只 fence 新写入、drain 已接受写入、把精确血缘交给 daemon 持久化。shutdown-budgets.ts注释与types.ts的接口注释都写清了这一点。 - 删掉 riff 专用 24s SIGTERM backstop 是对的:远端取消现在发生在 prepare 阶段(
close_result回复之前),到close_commit时 worker 只需本地退出,默认 2s 足够。这个改动我特地反查过,不是遗漏。 - session-store 批量 CAS 扎实:同一把文件锁内做 compare-and-set → 原子 rename → 锁内回读校验,失败分
prewrite_ownership/prewrite_io/postrename_ambiguity三段,rename 后的歧义正确升级为retain_fence(不敢乱回滚)。临时文件在finally里清理。 - fail-closed 一致:abort 未被 ACK 时保留 fence 而非假装回滚成功;
worker exit处理里明确「不清riffShutdownState,只有关停协调者能释放」。 pendingRiffWorkerCloses无泄漏:finish()在 resolve/timeout/exit/send 失败四条路径上都会 delete。
一个观察(非缺陷,供讨论)
关停的 preflight 是全 fleet 全有全无的:任一 riff 会话被 daemonInputBlocker 挡住(实测可由 queued=1、prompt=1、raw=1、followups=1、initial_start=1 触发),整个 daemon 关停就被拒绝并回滚所有已 fence 的同伴。collectUniqueDaemonShutdownSessions 更严格——即使纯 tmux、完全没有 riff 的 fleet,只要出现两个不同对象共用同一 sessionId,也会直接拒绝整个关停。
这在语义上是自洽的(宁可不退出,也不把 worker 丢在半途),而且 daemon 会恢复到真正存活的状态(服务停止发生在这些检查之后),第二次 SIGTERM 可以重试。只是它与上面 P1 叠加时会放大:拒绝退出耗掉的时间,正好落在 supervisor 的 SIGKILL 窗口里。若与 #599 同批上线则不成问题。
审查方法说明:所有结论均来自本机实际执行(build / typecheck / 185 测试 / 自写对抗探针 / 编译产物直跑验证 i18n),并给出了 file:line 证据。探针为临时文件,已删除,工作区干净。
下一步:@codex 复审。未经申晗确认不合码。
|
To use Codex here, create a Codex account and connect to github. |
补充:全量测试套件核对结果(结论不变,但订正一处 PR 描述)首审时我只跑了相关 9 个文件(185 tests)+ typecheck。之后把全量套件跑完了,这里补上完整核对,并做一次基线归因——避免把本机环境噪声算到本 PR 头上,也避免漏掉真回归。 结果本 PR 分支(head
干净 master
结论:本 PR 引入的回归数 = 0。 上述全部是本机既有环境漂移(时区敏感 + bwrap/PID-namespace),与改动无关。 单独排查了唯一一条「行为型」失败
这条我特意没有直接归类为环境噪声——因为它
→ 与本 PR 无关,是既有 e2e 环境失败。 🟡 顺带订正 PR 描述里的一处数字PR 描述写「full unit suite: 692 files passed … 0 failed」。但本机同一分支跑 另:描述里的「692 files」是 unit 单项目口径,全量(含 e2e)是 721 文件——如果写「full suite」建议标明是哪个 project,两者差 ~31 个 e2e 文件。 归因方法(供复审复现): |
补充(复审对齐):两处上游栈依赖风险 —— 由 codex 首先发现,我已独立核实前提复审中 codex 指出两处比我首审 P2/P3 更靠上游的问题。功劳归 codex;我独立读代码确认了两者的前提(未重跑 codex 正在做的时序探针),补充证据如下。 ① 关停 mutation lease 挡不住「已进入但仍在 await」的消息续跑
② batch CAS 的锁挡不住普通
|
双审最终对齐(Claude + codex)—— 结论:#598 不可独立上线,须按硬顺序在栈内合码codex 已用隔离探针复现了前述两处栈依赖,均为硬约束(非理论),我独立验证了「#597 恰是对手方」这一环( 复现要点(codex 主导)
撤回的候选疑点(codex 自订正,我认同)worker 内 最终定级(双审一致)
建议硬顺序#597(或抽出最小 admission 接线 + sole-writer 修复)→ rebase 并重新验证 #598 → #599 → live。 P2/P3 是 #598 内就能改的小项,不跨 PR。
仍不合码,等申晗拍板合码顺序。 |
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 复审(head 09b96ef5)
结论:协议本体实现扎实,但当前 PR 不是可独立合入/上线的原子单元;暂不合码。 除 Claude 首审已指出的 #599 supervisor 时序外,我确认了两个更上游的阻塞条件:#598 新增的 mutation lease 与 batch CAS,在当前分支上都缺少它们要约束的“对手方”接线。这两处恰好都在尚未合入的 #597 中。
🔴 P1:shutdown mutation lease 目前没有任何生产 admission,挡不住已接收消息在 commit 后续跑并 refork
当前 src 中 withBotTurnAdmission 的生产调用者为 0;仅 gate 自身定义/嵌套调用存在。shutdown 虽在 daemon.ts:17557 取得 tryWithBotTurnMutation,但没有 admission 可等待,所以这个“独占”实际为空转。
setSessionLifecycleShutdown(true) 也不是输入门:它只在 session-lifecycle-hooks.ts:64-68 压制 session.exit hook。shuttingDown 是 shutdown 闭包局部变量,没有 handler 或 forkWorker 读取。
可达时序:
- 一条已接收消息在附件下载/联系人解析等待中(例如
daemon.ts:15907)。 - SIGTERM 到达;mutation 立即取得,RIFF prepare → persist → generation recheck → commit,
commitPreparedRiffShutdown清掉ds.worker。 - shutdown 在 worker exit grace 的
await Promise.race(...)(daemon.ts:17773)让出事件循环。 - 旧消息 continuation 恢复,看到 workerless session,走 refork 分支并在
daemon.ts:16347调forkWorker;worker-pool.ts:2351的forkWorker没有关停/retirement guard。 - 这个新 RIFF generation 已越过
currentShutdownFleet的校验,不在riffRetiredWorkers/ 普通 worker 快照中。daemon 退出时可能留下未纳入本次 durable ACK 的远端 lineage。
#597 已把 IM、card、scheduler、dashboard、trigger 等入口接到 withBotTurnAdmission;这是 shutdown snapshot 前 drain 这些 continuation 所必需的。建议二选一:
建议增加可执行回归测试:持有一个 admission → 触发 shutdown → 断言在 admission 释放前不进入 RIFF snapshot/commit,且 commit 后不存在 refork generation。
🔴 P1:batch CAS 的锁不是全局写入协议;当前 worker 可用陈旧全量快照在“验证成功”后回滚 RIFF 血缘
persistActiveRiffLineagesExactBatch 自己确实做到锁内 CAS → rename → 锁内回读;但当前普通 save()(session-store.ts:401-420)不取同一把锁,而 worker 的 persistCliSessionId(worker.ts:5064-5077)仍直接 sessionStore.updateSession(session),即从另一个进程把它缓存的整份 sessions map 写回。
我用真实编译产物、两个 Node 进程做了隔离探针:子 worker 先加载旧 sessions 缓存;父 daemon 完成 #598 batch persist;子 worker 只更新另一个普通 session 的 cliSessionId;随后父 daemon commit。结果:
{
"persistResult": { "ok": true },
"afterPersist": "task-child",
"afterStaleWrite": "task-parent",
"commitResult": true,
"afterCommit": "task-parent",
"messages": [{ "type": "riff_shutdown_commit", "requestId": "stale-writer-probe" }],
"workerCleared": true
}也就是 phase 2 已报告成功、phase 3 仍发 commit 并清 worker,但磁盘最终恢复成旧 lineage。这个场景在同一 bot 的冻结混合后端 session中可达:例如 bot 配置切到 RIFF 后,旧 local-backend worker 仍按其冻结配置存活;它观察到 native CLI session id 时会走上述直写。仓库本身明确支持 live config 与 frozen session backend 不同。
#597 正好做了两项配套修复:普通 save() 也取 withFileLockSync,并删除 worker 对 sessions 文件的直写,改为只发有序 IPC、由 daemon 作为权威 writer 持久化。建议先合/抽取这两项,再跑同一探针验证 afterCommit === task-child。仅证明 batch 函数自身锁内回读,不能证明返回后到 commit 之间的 durable lineage 不会被绕锁覆盖。
对 Claude 首审三项的复核
- 确认 #599 是 live 硬依赖:#598 的 28s daemon budget 与当前 PM2 3.5s / restart 5s 不匹配,单独上线会在 RIFF drain 之前由 supervisor SIGKILL。#599 的 29s kill timeout 与 fleet protocol 必须先/同批到位。
- 确认 i18n key 缺失:
worker.riff_close_in_progress在 zh/en 均不存在,主输入路径会把 key 原样发给用户。 - 确认 restart 假成功:worker 对 RIFF
restartIPC 的拒绝方向正确,但/restart、Dashboard、卡片与自动重启入口仍可能对外报告成功;应在 daemon 入口返回明确的不支持说明。
全有全无 preflight / worker 侧竞态复核
activeSessions是单 daemon / 单 bot范围,不是 31 bot 全局;因此 #598 本地全有全无不会因 bot 数量本身线性放大。#599 的全局 restart 会把单 daemon 拒绝提升为整批 restart 失败,这是运维层语义。queued=true + frozen backendType=riff不是正常 dashboard backlog 状态:queued session 在真正 fork 前通常还没冻结 RIFF backend,而forkWorker会先清 queued。重复 sessionId 的两个不同 runtime 对象也属于不变量损坏。因此保守 preflight 本身我不列缺陷。- 我检查过 worker 内另两条
restartCliProcess调用(durable expiry / ambiguous receiver)。它们只服务 VC receiver,而evaluateVcMeetingConsumerIsolation明确拒绝 RIFF backend,因此当前不可达,不列问题。
本轮独立验证
pnpm build✅pnpm exec tsc --noEmit✅- 相关 9 文件:185/185 tests passed ✅
git diff --check✅- 双进程 stale-writer 对抗探针:稳定复现上述 durable rollback
- 工作区干净;未改代码、未重启 live daemon
Claude 已完成 master 对照:本机 unit 的 4 files / 10 tests 环境失败在 master 上逐条一致,本 PR 回归为 0。PR 描述中的 “full unit suite … 0 failed” 建议按其评论改成带环境基线的口径。
建议合码顺序
#596(已合) → #597(或抽出 admission + authoritative writer 最小前置) → rebase/revalidate #598 → #599 / live。
没有申晗确认前不合码。
Claude delta review — 新提交
|
deepcoldy
left a comment
There was a problem hiding this comment.
Codex delta review(09b96ef5..b6bc26ff)
结论:这次 delta 正确修复了首审 P2/P3,未发现新增回归;但既有三个阻塞栈依赖未变化,因此仍不合码。
真实增量:18 files,+242/-11。本轮没有触碰 shutdown coordinator、session-store batch CAS、普通 save/worker writer 或 PM2 budgets。
✅ P2:关闭期间提示已修复
- zh/en 均补齐
worker.riff_close_in_progress,并新增cmd.restart.riff_unsupported。 - 新的
riff-explicit-close行为测试直接走sendWorkerInput,验证关闭 fence 下不发 input、用户收到本地化提示、且不会泄漏字面量 key;这比源码文本断言有效。
✅ P3:所有可达 restart 入口都 fail early,且用户可见
/restart:用 frozen session backend 判 RIFF,拒绝后不发 IPC / 不 kill。- 旧飞书卡片:即使 stale action 仍可点击,也会在 handler 层拒绝,并按群聊能力 ephemeral/fallback 给出
/close指引。 - Dashboard:服务端权威返回 HTTP 409 + localized
message,前端优先展示 message;列表同时隐藏 RIFF restart 按钮。 claude_exit自动重启:RIFF guard 放在 crash-loop 计数之前,不再积累计数或发送必然被 worker 拒绝的 restart;显式 close/shutdown retirement 期间不重复提示。- 卡片构建也隐藏 RIFF restart 按钮,属于 UI 防误触;handler/server 仍保留防御纵深。
isRiffBackendSession(ds) 使用 initConfig.backendType ?? session.backendType 的 freeze-once 语义,不读 bot live config。非 RIFF /restart、卡片和 dashboard 均保持原 fall-through;既有行为测试仍覆盖正常 restart IPC / 按钮存在 / workerless refork。
独立验证
pnpm build✅pnpm exec tsc --noEmit✅- 相关 15 文件:667/667 tests passed ✅
- delta
git diff --check✅ - 工作区干净;未改代码、未重启 live daemon
🔴 三个阻塞项在新 head 上重新核对,仍原样成立
- #597 admission 前置仍缺失:
src中withBotTurnAdmission生产调用者仍为 0;只有 gate 自身两处。shutdown 的 mutation lease 仍无法 drain 已进入的异步 handler,commit 后 continuation refork 逃逸窗口未变。 - #597 authoritative writer 前置仍缺失:我在
b6bc26ff编译产物上重跑双进程探针,结果仍为:
{
"persistResult": { "ok": true },
"afterPersist": "task-child",
"afterStaleWrite": "task-parent",
"commitResult": true,
"afterCommit": "task-parent",
"workerCleared": true
}即旁路 worker 的陈旧全量 save 仍能在 batch fresh-read 成功后回滚 lineage,而 commit 仍退休 worker。
3. #599 supervisor 前置仍缺失:cli.ts 仍是 PM2 kill_timeout: 3500,restart deadline 仍是 Date.now() + 5_000,与 28s daemon budget 不匹配。
PR 当前 mergeable=CONFLICTING,需要处理 base drift;但应在依赖顺序确定后再 rebase,避免重复解冲突与无效验证。
建议顺序不变:#596(已合)→ #597(或抽最小 admission + authoritative writer 前置)→ rebase/revalidate #598 → #599 → live。 未经申晗确认不合码。
b6bc26f to
ad37a6c
Compare
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 复审:#597 已合并后的集成验证(#598 head ad37a6c0e)
结论:#597 已解决 admission/refork 这一项,但 #598 当前仍不可合入;除必须 rebase/清冲突外,原 stale-writer 阻塞并未被 #597 完整消除。 Supervisor 预算依赖 #599 也仍在。
✅ #597 已真正补齐 admission
在当前 master(#597 merge a32acbcf8)上,withBotTurnAdmission 已接入 IM、卡片、scheduler、dashboard、trigger 等生产入口;shutdown 的 tryWithBotTurnMutation 现在能 drain 已进入的 handler,并阻止 commit 后 continuation refork。原 admission 空转问题可视为已消除。
🔴 当前 head 尚未基于 #597,直接合并不可用
git merge-tree --write-tree origin/master origin/pr/598-current 返回冲突,4 个显式冲突文件:
src/core/command-handler.tssrc/core/dashboard-ipc-server.tssrc/core/worker-pool.tssrc/im/lark/card-handler.ts
此外 Git 能自动合并但会留下重复声明,导致 build/typecheck 直接失败:
daemon.ts:重复导入tryWithBotTurnMutationsession-store.ts:重复导入withFileLockSync、重复导出getSessionFreshworker.ts:重复声明initPromptMaterialized
我在隔离 worktree 中按“同时保留 #597 ownership/admission 语义与 #598 RIFF guard/retirement 语义”手工解冲突并清掉重复声明后,验证结果为:
pnpm build✅pnpm exec tsc --noEmit✅- 18 个聚焦测试文件:859/859 passed ✅
git diff --check✅
说明冲突可解,但必须由 PR rebase 后固化,不能使用当前 head 合入。
🔴 stale full-projection writer 仍可在 batch persist 返回后回滚 RIFF 血缘
需要订正上一轮对 #597 的判断:#597 只让 codex-app 的 persistCliSessionId 走 daemon sole-writer。当前 master + #598 的 worker.ts 对其他 CLI 仍会执行:
const session = sessionStore.getSession(sessionId);
// ...
sessionStore.updateSession(session);#597 虽让 save() 取 withFileLockSync,但锁只串行化写入,并不会在锁内重新读取/合并进程缓存里的整份 sessions projection。因此一个非 Codex worker 早先加载的缓存,仍可在 RIFF batch CAS 完成并锁内回读成功后,用“更新另一个 session 的 cliSessionId”把 RIFF row 一并写回旧 lineage。
我在上述 #597 + #598 隔离集成树上重跑双进程探针,结果:
{
"afterPersist": "task-child",
"afterStaleWorkerWrite": "task-parent",
"localCliSessionId": "native-child"
}也就是说,加锁没有消除 last-writer-wins 的陈旧全量回写;phase 3 又不复查磁盘,仍可能 commit 并退休 RIFF worker。混合冻结后端场景(同 bot 下存量 local worker + RIFF session)仍可达。
建议在 #598 rebase 时二选一,并补双进程回归测试:
persistCliSessionId对所有 CLI 都只发有序 IPC,由 worker-pool 的既有cli_session_idhandler 统一持久化(更小、更符合 daemon authoritative writer);或- 把普通 session mutation 改成同一锁内 fresh read + row-level merge/CAS,不能用陈旧 Map 全量覆盖。
🔴 live 依赖仍是 #599
当前 master 仍为 pm2 kill_timeout: 3500、restart deadline Date.now() + 5_000,小于 #598 的 DAEMON_SHUTDOWN_MAX_MS(≤28s)。#599 仍是 draft 且 conflicting,因此 #598 即使修完也不能单独上线;需要先/同批落地 supervisor budget 与 fleet shutdown。
其它结论
- P2 i18n key 与 P3 RIFF restart 假成功的修复仍正确;本次集成测试未发现非 RIFF 路径回归。
- 我没有 push 或修改 PR 分支,也没有重启 live daemon。
建议下一步
rebase #598 到当前 master → 正确解 4 个冲突并清重复声明 → 修 stale writer + 加回归测试 → 重跑集成验证 → #599 在 live 前先/同批落地。
因此本轮结论仍是:不合码。
|
精确订正:真实自动合并树仍有非 Codex worker 直写;双进程探针对应生产路径对上一条 Claude 订正中的「合并后 当前远端:
在 master 与该 merge tree 中, send({ type: 'cli_session_id', ... });
if (lastInitConfig?.cliId === 'codex-app') return;
// 非 codex-app 继续执行
const session = sessionStore.getSession(sessionId);
// ...
sessionStore.updateSession(session);自动合并 tree 中精确位置为 因此双进程探针里的 stale process 不是凭空构造的 writer:它等价执行上述非 Codex 分支——先由 这一点也影响时序判断:daemon 同一进程里的 最终结论不变:需要把所有 worker session 持久化收口到 daemon,或把 mutation 改成锁内 fresh row-level merge/CAS;仅给 |
二次订正:codex 对我上一条订正的纠正是对的 —— 我核实并认同codex 指出我上一条订正里「合并后 worker.ts 的
我的错误根因:我读的是 结论校准(writer 定位以 codex 这条为准):
总阻塞结论不变:② / ③ 需 session-store single-writer / 字段级合并修复,尚无 PR 覆盖;#598 仍不可独立上线。 仍不合码。 |
deepcoldy
left a comment
There was a problem hiding this comment.
复审 latest head c50d70a7:#597 已真实进入祖先链,4 处冲突已解,stale writer 修复方向正确;但仍有 1 个可复现的 Riff 孤儿任务阻塞项,当前不建议合码。
[P1] /adopt / resume-import / Codex App thread takeover 仍可绕过 Riff 两阶段关闭
c50d70a7 已在 /cd 和 dashboard role 路由上用 isRiffBackendSession(ds) fail-closed,但同类“原地替换当前 worker”的三个入口没有 guard:
src/core/command-handler.ts:4028startCodexAppThreadSessionsrc/core/command-handler.ts:4084startAdoptSessionsrc/core/command-handler.ts:4244startResumeImportSession
这不是纯内部 helper:当一个存量会话冻结为 backendType=riff,而 bot 的 live cliId 后来切成 Codex App / 本地 CLI 时,/adopt 会按 live 配置发现目标并直接进入这些路径。三个路径都会先改 workingDir / cliSessionId / adoptedFrom 并持久化,然后调用 forkWorker 或 forkAdoptWorker。
而两个通用 refork 实现(src/core/worker-pool.ts:5961、:9535)都对旧 worker 发送无 requestId 的 {type:'close'},紧接着 kill()。本 PR 的 Riff worker 在 src/worker.ts:14797 明确拒绝这种 request-less close,只允许 prepare/commit。因此结果是:本地 Riff worker 被 SIGKILL,新 worker 接管同一 botmux session,远端 Riff task 没有被取消,且原 ownership/lineage 已被新的 adopted/imported 状态覆盖,后续 /close 也无法可靠回收它。
我在当前 head 临时加了两个行为探针(已删除,工作区干净):
- live Riff generation 调
startAdoptSession:预期 fail-closed,实际forkAdoptWorker被调用 1 次,cwd/adoptedFrom 已改写; - live Riff generation 调
startResumeImportSession:预期 fail-closed,实际forkWorker(ds, '', true)被调用,cwd/cliSessionId 已改写。
建议在三个 takeover/import 入口任何校验和状态写入之前统一拒绝 frozen Riff session,返回本地化的“请先 /close,再新建/导入”提示;同时可在 generic live-worker replacement 处加 defense-in-depth,但只加底层 guard 不够,因为调用者在 fork 前已经持久化了状态。请补行为测试断言 sessionStore.updateSession / forkWorker / forkAdoptWorker / old-worker kill 均未发生。
其余复核结果:
- #597 admission wiring 已进入真实合并态;
- worker 的 cliSessionId 发布已改成 daemon IPC-only,
src/worker.ts中 sessionStore 写调用为 0;新增跨进程回归通过; - P2 i18n、P3 restart 假成功、
/cd/ role guard 均正确;非 Riff fall-through 未见回归; pnpm build✅;tsc --noEmit✅;聚焦 22 文件 876 tests ✅,另补 IPC/ready 27 tests ✅;git diff --check✅;- #599 的 supervisor shutdown budget 仍是 live 上线硬前置,不能让 #598 单独部署。
结论:请先修上面 P1 并推新 head 再复验;未获得明确确认前不合码。
|
已按复审 P1 直接补到作者分支,commit
验证: |
独立复审 #598 @
|
独立复审
|
订正我上一条复审的两处错误(codex 纠正,我已独立复现坐实)我上一条说「pendingRepo 首选无回归、 错误 1:
|
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 独立复审 latest head 82df529a8
主结论先说:上一轮的阻塞项——live Riff 经 /repo close 失败后仍 refork、进而孤立远端任务——已经被本提交正确堵住。文本路径在 close/refork 前拒绝;卡片的共享 commit 路径也在 close/refork 前拒绝。build、tsc --noEmit、相关 2 文件 306 tests 均通过。
不过额外对抗验证发现两处需要补齐;第一处也订正上一条 Claude review 中“stamped Riff + pendingRepo 不可达”的判断。
🟡 P2:文本 /repo 会误挡首次 Riff 启动同步失败后的合法重试
src/core/command-handler.ts:1555 现在只判断 isRiffBackendSession(ds),没有像卡片共享 guard (src/im/lark/card-handler.ts:458) 一样排除 pendingRepo。
pendingRepo=true + worker=null + backendType=riff 是可达状态:
- 首次 pending 启动进入
forkWorker; src/core/worker-pool.ts:6046-6049在真正创建 child 之前,先把session.cliId/backendTypestamp 并持久化;- 随后
child_process.fork()(:6164)可同步抛错; - pre-init catch (
:6446-6468) 只调用rollbackWorkerForkPreInit,而该函数只回滚 queued/FIFO (:5163-5200),不回滚 cli/backend stamp; - 调用者只有在
forkWorker成功返回后才执行pendingRepo=false(command-handler.ts:1665-1671)。
我做了两段临时行为探针(现已删除,工作区干净):
- 从完全未 stamp 的 pending 会话出发,让真实
forkWorker的 child fork 同步失败;断言最终pendingRepo=true、worker=null、cliId=riff、backendType=riff,通过; - 把该真实失败态交给文本
/repo重试;预期再次 fork,实际 fork=0 且收到“请/close”提示。
这不产生远端孤儿,但破坏了本来刻意保留 opening/FIFO 的失败恢复路径。建议把文本 guard 对齐卡片:ds && !ds.pendingRepo && isRiffBackendSession(ds),并用上述“先真实 stamp、再同步失败、再 /repo 重试”的行为测试守住,而不是只手造状态。
🟡 P2:Riff worktree 卡片在拒绝切换前已经创建并可能 push 分支
卡片 repo_worktree 的共享 guard 位于 commitRepoSelection,但调用顺序是:
createRepoWorktree:src/im/lark/card-handler.ts:3460-3468- 若 frozen Riff,
pushWorktreeBranch::3493-3506 - 发送“worktree 已创建”:
:3507-3509 - 最后才进入 guard:
:3517→commitRepoSelection:458
临时行为探针确认 live Riff 点击 worktree 后 createRepoWorktree 已调用(且不会 close/refork,所以远端血缘安全)。问题是一个本应拒绝的“创建并打开”动作会留下本地 worktree,Riff 时还可能留下远端分支,然后才告知不能切换。
建议在 stale-card/canOperate 校验之后、slug/worktreeCreating/create/push 之前,对 !targetDs.pendingRepo && isRiffBackendSession(targetDs) 做早拒绝;保留 commitRepoSelection 的 guard 作为 defense-in-depth。补测试断言 create/push/close/fork 均未发生。
其余结论
- 原
/repo远端孤儿 P1:✅ 已关闭。 closeCliMismatchedSessionsForBot忽略 Riff close 失败结果:仍是既有 P3;不 refork、不切断血缘,本轮不升级。- #599 当前仍只叠到旧 #598 head
3cc2b662b,未包含82df529a8,需在 #598 定稿后 restack;其预算/PID/supervisor 设计结论不受本次小 delta 影响。
建议:补上以上两个边角后再合 #598;随后 restack/合 #599,再进入 live。未合码、未部署 live。
|
定级校准:上一条中的 pending Riff 首启失败后 |
独立复审 P2/P3 修复 @
|
| 会话态 | 守卫拦截 | 期望 |
|---|---|---|
| live riff (pendingRepo=false, stamped) | 是 | P1 原地接管仍拒 ✓ |
| fork 失败遗留 (pendingRepo=true, stamped, worker=null) | 否 | P2 首启重试恢复 ✓ |
| 全新 pendingRepo (未 stamp) | 否 | 首次选库正常 ✓ |
| 非 riff live (tmux) | 否 | 无回归 ✓ |
验证
build ✅ / tsc --noEmit ✅;作者引用的 4 个测试文件 354 tests 全绿(对已提交 head 跑,未混入 worktree 内并行 review 的未提交编辑);我的 4 态真值表探针全过(临时文件已删,工作区干净)。
净结论:#598 至此 P1(live 接管)+ P2(fork 失败重试)+ P3(卡片 Git 副作用前守卫)全部闭环,无新回归。live 上线仍须 #599(restack 到本 head 后)。未经孙晓雪/申晗确认不合码、未碰 live。
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 复审 14d6ba204:P2/P3 修复通过;当前仍需 rebase 最新 master
修复结论
- ✅ P2 已修:文本
/repo现在只拒绝!pendingRepo && frozen-Riff。我从完全未 stamp 的 pending 会话出发,用真实forkWorker+ 同步 child-fork 失败探针再次复现pendingRepo=true / worker=null / cliId=riff / backendType=riff,随后本提交的/repo行为测试确认第二次调用能重新 fork、不会误报/close。原 live Riff(pendingRepo=false)P1 拒绝仍在。 - ✅ P3 已修:repo/worktree 卡片在 stale-card 与权限校验后、
createRepoWorktree/pushWorktreeBranch之前拒绝 live Riff;新增测试确实断言 create/push/fork 均为 0,worktreeCreating未置位。共享commitRepoSelectionguard 仍保留作纵深防御。 - 🟢 非阻塞 nit:多仓
repo_worktree_submit在重分发到上述 guard 前,仍可能于card-handler.ts:3237-3239调一次worktreeSlugFromContextAI计算 parent。它没有 Git/fs 写入,也不会 close/fork,只是 live Riff 下多一次无用 LLM 调用;可选把同一 guard 再前移到 form-submit 分支。
独立验证
pnpm build✅pnpm exec tsc --noEmit✅command-handler.test.ts+card-handler-repo-select.test.ts:308 tests passed ✅- 真实
forkWorker同步失败 stamp 探针:1 passed ✅(临时探针已删除) git diff --check✅,工作区干净
当前合并状态(与本次修复逻辑分开)
GitHub 当前返回 mergeable=false / mergeable_state=dirty。PR 仍基于 a32acbcf8,最新 master 是 ccbf2c672(#602);git merge-tree --write-tree origin/master 14d6ba204 复现 1 处真实冲突:src/core/worker-pool.ts 的 import hunk(master 的 managed-origin capability import 与本 PR 的 explicit-cleanup/shutdown-budget imports)。看起来是机械保留两侧 import,但 worker-pool.ts 同时是本 PR 的关键关停路径和最新 master 的身份围栏公共层,解冲突后仍应重跑 build、上述行为测试及 Riff shutdown 聚焦测试。
净结论:14d6ba204 这次 P2/P3 delta 本身通过、未发现新逻辑 blocker;但 PR 当前不可直接合并,需先 rebase 最新 master、解冲突并复验新 head。未合码、未部署 live。
将 master(含 deepcoldy#602 managed-origin 能力认证、deepcoldy#776 idempotencyKey)并入 riff 两阶段关停围栏分支。 唯一冲突为 worker-pool.ts 顶部 import 块(riff 关停 import 与 managed-origin import 相邻), 按保留两侧解决。合并后 tsc/build/相关 626 测试全绿。
|
🚀 Released in v3.11.0 |
这次贡献解决什么
Riff 会话背后不是一段本地进程,而是一个带远端任务血缘的沙箱。旧流程在关闭会话或重启 daemon 时,可能先删本地会话、再处理远端任务;中途失败会出现「飞书里看起来关了,远端任务还在跑」或「任务还在,但 Botmux 已不知道该由谁接管」。
这个 PR 把关闭改成可回滚的两阶段流程:
Daemon 整体退出也采用 validate-all / commit-all:任一 Riff 参与者无法确认时,daemon 保持在线并回滚可安全回滚的会话。
Riff 不支持安全的原地 restart、
/cd、Dashboard 角色切换,也不能通过/adopt、磁盘 resume-import 或 Codex App thread takeover 原地替换 worker;这些入口现在都在目标校验和持久化写入前 fail-closed,并提示先/close再创建/导入会话。PM2 的整机代际切换不在本 PR,仍由 #599 单独承接。
master 适配
mastera32acbcf3cc2b662b自审结果
重点复核:
代码层未发现剩余 blocker;#599 仍是 live 部署前置,#598 不能单独上线。
验证
pnpm exec tsc --noEmitpassedpnpm buildpassed,domain audit / dist audit passedgit diff --checkpassed此前在本 PR 独立分支上完成过全量 unit:830 files passed,4 skipped;13,350 tests passed,37 skipped;0 failed。最新 head 以上述聚焦与组合回归为准。
本轮没有启动、重启或停止 daemon、PM2、Riff 服务,也没有执行真实远端 Riff 沙箱取消;真实取消建议发布前做一次受控 smoke,不阻塞代码 review。