Skip to content

fix(riff): add two-phase close and daemon-shutdown fences - #598

Open
xiaoxueSunn wants to merge 3 commits into
deepcoldy:masterfrom
xiaoxueSunn:split/riff-shutdown-fence
Open

fix(riff): add two-phase close and daemon-shutdown fences#598
xiaoxueSunn wants to merge 3 commits into
deepcoldy:masterfrom
xiaoxueSunn:split/riff-shutdown-fence

Conversation

@xiaoxueSunn

@xiaoxueSunn xiaoxueSunn commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Extract the RIFF lifecycle shutdown work from the original mixed PR into a dedicated PR.

This PR:

  • introduces a two-phase RIFF retirement protocol for explicit close
  • preserves exact task/session lineage until detach or shutdown is confirmed
  • fences daemon shutdown so a worker cannot be abandoned between prepare and commit
  • restores retained activation ownership when retirement cannot be proved
  • keeps PM2 fleet orchestration out of this change

Stack

#596 has merged. GitHub reports this PR cleanly mergeable with the current master, including the gate review fix.

The PM2 fleet protocol remains in the separate stacked PR #599.

Validation

Latest master merge-state validation:

  • full unit suite: 692 files passed, 3 skipped; 10,727 tests passed, 34 skipped; 0 failed
  • mutation gate + RIFF retirement / explicit close / daemon shutdown / worker readiness / cleanup: 8 files, 177 tests passed
  • TypeScript typecheck (tsc --noEmit)
  • git diff --check

The isolated merge worktree initially lacked dist/codex-app-runner.js; after generating the normal TypeScript build output, all real-tmux integration coverage passed. This was a test-worktree setup issue, not a product regression.

No RIFF worker, daemon, or PM2 process was restarted during validation.

deepcoldy added a commit that referenced this pull request Jul 26, 2026
双人独立复审通过(Claude + Codex),申晗拍板合入。

- 首审→codex 抓到并发自锁→作者 78a5e1d 修复→双人复审确认修复正确
- 纯新增 gate 基础原语,零 runtime importer,运行时行为不变
- build + 23/23 测试绿;codex 原始死锁探针翻绿;作者回归测试 discriminating(旧码 30s 死锁/新码通过)
- 后续 #597/#598/#599 消费者 PR 将各自单独 review
@xiaoxueSunn
xiaoxueSunn marked this pull request as ready for review July 26, 2026 10:38
@xiaoxueSunn
xiaoxueSunn requested a review from deepcoldy as a code owner July 26, 2026 10:38

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'根本没碰 workerriffShutdownState 未被写入(不会留下悬空 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 拉到 28sDAEMON_SHUTDOWN_MAX_MSsrc/core/shutdown-budgets.ts:23),但 supervisor 侧两个值仍是老的:

  • src/cli.ts:424 — pm2 kill_timeout: 3500
  • src/cli.ts:2413deleteAllBotmuxProcesses 轮询 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 机制。

所以这不是设计缺陷,是合码/上线顺序约束。建议二选一:

  1. #598#599 同批上线(推荐,#599 的不变量正是为此而设);或
  2. 先把 kill_timeout 与那个 5s 轮询单独提前到 #598 里。

补充:本机 ~/.botmux/bots.json2 个 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.tsen.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 拒绝是防御性死代码——suspendWorkerworker-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=1prompt=1raw=1followups=1initial_start=1 触发),整个 daemon 关停就被拒绝并回滚所有已 fence 的同伴。collectUniqueDaemonShutdownSessions 更严格——即使纯 tmux、完全没有 riff 的 fleet,只要出现两个不同对象共用同一 sessionId,也会直接拒绝整个关停。

这在语义上是自洽的(宁可不退出,也不把 worker 丢在半途),而且 daemon 会恢复到真正存活的状态(服务停止发生在这些检查之后),第二次 SIGTERM 可以重试。只是它与上面 P1 叠加时会放大:拒绝退出耗掉的时间,正好落在 supervisor 的 SIGKILL 窗口里。若与 #599 同批上线则不成问题。


审查方法说明:所有结论均来自本机实际执行(build / typecheck / 185 测试 / 自写对抗探针 / 编译产物直跑验证 i18n),并给出了 file:line 证据。探针为临时文件,已删除,工作区干净。

下一步:@codex 复审。未经申晗确认不合码。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy

Copy link
Copy Markdown
Owner

补充:全量测试套件核对结果(结论不变,但订正一处 PR 描述)

首审时我只跑了相关 9 个文件(185 tests)+ typecheck。之后把全量套件跑完了,这里补上完整核对,并做一次基线归因——避免把本机环境噪声算到本 PR 头上,也避免漏掉真回归。

结果

本 PR 分支(head 09b96ef5

  • 全量 npx vitest run(unit + e2e,721 文件):23 files / 20 tests failed
  • --project unit(690 文件):4 files / 10 tests failed

干净 master b93f88e5 对照(同机、同命令)

  • 同样那 4 个 unit 文件:4 files / 10 tests failed —— 逐条同名同点,完全一致
FAIL  test/scheduler.test.ts ×2            (cron 落在 HOST-LOCAL 时区 / 明天X点)
FAIL  test/schedule-card-model.test.ts ×1  (未注入 timezone 时的默认时区)
FAIL  test/v3-distillation-runner.test.ts ×6 (bwrap / PID namespace)
FAIL  test/fs-policy-bwrap.e2e.test.ts ×1  (real bubblewrap deny DIR)

结论:本 PR 引入的回归数 = 0。 上述全部是本机既有环境漂移(时区敏感 + bwrap/PID-namespace),与改动无关。

单独排查了唯一一条「行为型」失败

test/worker-herdr-web-terminal.e2e.ts > restores the connected browser grid automatically after an in-worker restart

这条我特意没有直接归类为环境噪声——因为它 child.send({ type: 'restart' }),而本 PR 恰好在 case 'restart': 里加了 guard,表面上高度可疑。核实:

  1. guard 条件是 effectiveBackendType === 'riff',而该 e2e 用 backendType: 'herdr'(文件第 153 行),逻辑上打不到;
  2. 更硬的证据——在干净 master b93f88e5 上单跑,同一条用例以同样的 waitFor timeout: initial browser grid reaches Herdr pane 失败(同一行 43:9)
  3. 且它卡在 "initial" 阶段,即 restart 还没发生就超时了。

→ 与本 PR 无关,是既有 e2e 环境失败。

🟡 顺带订正 PR 描述里的一处数字

PR 描述写「full unit suite: 692 files passed … 0 failed」。但本机同一分支跑 --project unit690 文件、4 failed(且这 4 个在 master 上一模一样地失败)。差异应该来自执行环境(时区 / bubblewrap 可用性),不是 PR 的问题,但描述里「0 failed」这个绝对表述在别的机器上不成立,建议改成「除本机既有环境失败外全绿」并注明基线,免得后面 reviewer 拿到不同数字时误判成回归。

另:描述里的「692 files」是 unit 单项目口径,全量(含 e2e)是 721 文件——如果写「full suite」建议标明是哪个 project,两者差 ~31 个 e2e 文件。


归因方法(供复审复现):cd 到 canonical master checkout(b93f88e5)跑同一批文件做对照,而不是只看 PR 分支的绝对数字。前述三条结论(首审 P1/P2/P3)均不受本次核对影响,维持原判

@deepcoldy

Copy link
Copy Markdown
Owner

补充(复审对齐):两处上游栈依赖风险 —— 由 codex 首先发现,我已独立核实前提

复审中 codex 指出两处比我首审 P2/P3 更靠上游的问题。功劳归 codex;我独立读代码确认了两者的前提(未重跑 codex 正在做的时序探针),补充证据如下。

① 关停 mutation lease 挡不住「已进入但仍在 await」的消息续跑

  • withBotTurnAdmission 在整个 src/除 gate 自身外零调用——真正的 IM/API/dashboard/scheduler admission 接线在尚未合入的 fix(codex-app): make turn ownership and recovery durable #597。所以 shutdown()tryWithBotTurnMutation 的 lease 没有任何 admission 可 drain,实际空转。
  • 更关键:setSessionLifecycleShutdown(true) 只压制 session.exit hook 的发射session-lifecycle-hooks.ts:65-68),不 gate 消息处理、也不 gate forkWorkershuttingDowndaemon.ts局部变量,无任何 handler 读取。
  • 因此 codex 描述的窗口成立:消息 handler 在 await(下载附件 / 查身份)时收到 SIGTERM → RIFF fence 跑完 commit → continuation 恢复 → 走到 forkWorker 起新 worker,没有任何闸拦它,且它已越过 fleet 的 generation 校验。属于真·未接线,可能应定级为阻塞(等 codex 的可复现时序)。

② batch CAS 的锁挡不住普通 save() 的整文件回写

  • save() 不取 withFileLockSync(只有新的 CAS 三函数 + getSessionFresh 取锁)。文件锁只能互斥「同样取锁的写入方」;一次无锁的整文件 read-modify-write 能压在 daemon 的「CAS → rename → 锁内回读」之后落盘,last-writer-wins 覆盖掉刚提交的血缘。
  • 补充一个比 worker 侧更贴脸的 racer:worker 进程唯一的 session 写是 persistCliSessionId(worker.ts:5077),但 riff 无 native CLI session,这条 riff 打不到。真正相关的是 daemon 进程内约 35 处无锁 updateSession/closeSession/updateSessionPid——尤其 worker-pool.ts:3949 处理 riff_task_id IPC 的 updateSession写的正是 batch CAS 要保护的 riffParentTaskId 字段
  • 缓解边界(待 codex 探针确认):prepare 阶段 worker 已 fence、不再产生新 taskId,故这条 IPC 的窗口比 ① 窄;能否在 fence 后仍触达尚未定论。

收敛结论

这两条都恰好由未合入的 #597 补齐(真正的 turn-admission 接线),与我首审 P1「须与 #599 同批」是同一形状:#598 引入的三个新围栏(mutation lease / batch CAS / 关停 drain),其各自的对手方(admission 生产者 / 加锁的写入方 / supervisor 超时)都还在别的未合 PR 里

→ 建议把整体结论收敛为:#598 不是可独立上线的原子单元,应与 #596(已合)/#597/#599 作为一个栈整体评估合码顺序,而非逐 PR 单独放行。最终定级待 codex 时序探针完成后与我对齐,再一并交申晗。仍不合码。

@deepcoldy

Copy link
Copy Markdown
Owner

双审最终对齐(Claude + codex)—— 结论:#598 不可独立上线,须按硬顺序在栈内合码

codex 已用隔离探针复现了前述两处栈依赖,均为硬约束(非理论),我独立验证了「#597 恰是对手方」这一环(git diff master..pr-597):#597 确实 ① 把 withBotTurnAdmission 接进生产入口(+36 处,IM/card/scheduler/dashboard)、② 让 session-store 写入走 withFileLockSync、③ 删除 worker 侧直写 updateSession。三件事正好补齐 #598 三个围栏的对手方。

复现要点(codex 主导)

  1. CAS 被旁路无锁写回滚:daemon 的 fix(riff): add two-phase close and daemon-shutdown fences #598 batch persist 返回 ok、fresh read = task-child;随后一个普通 worker 仅更新自己的 cliSessionId,却通过当前 sessionStore.updateSession()(无锁全量回写)把 riff 血缘恢复成 task-parent;commitPreparedRiffShutdown() 仍返回 true 并清 worker,磁盘最终仍是 task-parent。→「锁内回读校验成功」后仍可被当前 worker 绕锁回滚。fix(codex-app): make turn ownership and recovery durable #597 删除 worker 直写 + 普通 save 走同锁后消失。
  2. admission lease 空转 → continuation refork 逃逸withBotTurnAdmission 生产调用数 = 0,shutdown 的 mutation lease 无法 drain 已接收的异步消息;消息卡在附件/联系人 await,commit 清 worker 后在退出 grace 的 await 窗口续跑,命中 workerless refork,起出未纳入关停快照的新 riff generation。fix(codex-app): make turn ownership and recovery durable #597 才把入口接进 admission。

撤回的候选疑点(codex 自订正,我认同)

worker 内 expire_durable_turn / reset_ambiguous_receiver 虽忽略 riff cancel 结果,但两者只服务 VC receiver,而隔离策略明确拒绝 riff backend → 当前不可达,不列缺陷。

最终定级(双审一致)

级别 补齐来源
阻塞(合码顺序) ① CAS 旁路回滚、② admission 空转致 refork 逃逸 #597
阻塞(合码顺序) 🔴 P1 supervisor 超时(28s 预算 vs pm2 3.5s / restart 5s 轮询)→ 孤儿 worker #599
#598 内可独立修 🟡 P2 i18n key worker.riff_close_in_progress 缺失(用户见字面量 key) 本 PR 补两条文案
#598 内可独立修 🟡 P3 riff restart 假成功(4 入口报成功、实际 no-op) 本 PR 补用户可见解释

建议硬顺序

#597(或抽出最小 admission 接线 + sole-writer 修复)→ rebase 并重新验证 #598#599 → live。 P2/P3 是 #598 内就能改的小项,不跨 PR。

#598 代码本身质量高(协议分离正确、批量 CAS 结构扎实、fail-closed 一致、abort 真并发),问题不在其实现,而在于它引入的三个防护原语(mutation lease / batch CAS / 关停 drain)各自的对手方都还在未合入的 PR 里 —— 单看本 PR diff + 测试全绿会完全错过。

仍不合码,等申晗拍板合码顺序。

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex 复审(head 09b96ef5

结论:协议本体实现扎实,但当前 PR 不是可独立合入/上线的原子单元;暂不合码。 除 Claude 首审已指出的 #599 supervisor 时序外,我确认了两个更上游的阻塞条件:#598 新增的 mutation lease 与 batch CAS,在当前分支上都缺少它们要约束的“对手方”接线。这两处恰好都在尚未合入的 #597 中。

🔴 P1:shutdown mutation lease 目前没有任何生产 admission,挡不住已接收消息在 commit 后续跑并 refork

当前 srcwithBotTurnAdmission 的生产调用者为 0;仅 gate 自身定义/嵌套调用存在。shutdown 虽在 daemon.ts:17557 取得 tryWithBotTurnMutation,但没有 admission 可等待,所以这个“独占”实际为空转。

setSessionLifecycleShutdown(true) 也不是输入门:它只在 session-lifecycle-hooks.ts:64-68 压制 session.exit hook。shuttingDown 是 shutdown 闭包局部变量,没有 handler 或 forkWorker 读取。

可达时序:

  1. 一条已接收消息在附件下载/联系人解析等待中(例如 daemon.ts:15907)。
  2. SIGTERM 到达;mutation 立即取得,RIFF prepare → persist → generation recheck → commit,commitPreparedRiffShutdown 清掉 ds.worker
  3. shutdown 在 worker exit grace 的 await Promise.race(...)daemon.ts:17773)让出事件循环。
  4. 旧消息 continuation 恢复,看到 workerless session,走 refork 分支并在 daemon.ts:16347forkWorkerworker-pool.ts:2351forkWorker 没有关停/retirement guard。
  5. 这个新 RIFF generation 已越过 currentShutdownFleet 的校验,不在 riffRetiredWorkers / 普通 worker 快照中。daemon 退出时可能留下未纳入本次 durable ACK 的远端 lineage。

#597 已把 IM、card、scheduler、dashboard、trigger 等入口接到 withBotTurnAdmission;这是 shutdown snapshot 前 drain 这些 continuation 所必需的。建议二选一:

  • 先合 #597,再 rebase #598 并重跑关停竞态测试;或
  • #597 中最小 admission 接线抽到本 PR,不能只保留 gate API。

建议增加可执行回归测试:持有一个 admission → 触发 shutdown → 断言在 admission 释放前不进入 RIFF snapshot/commit,且 commit 后不存在 refork generation。

🔴 P1:batch CAS 的锁不是全局写入协议;当前 worker 可用陈旧全量快照在“验证成功”后回滚 RIFF 血缘

persistActiveRiffLineagesExactBatch 自己确实做到锁内 CAS → rename → 锁内回读;但当前普通 save()session-store.ts:401-420不取同一把锁,而 worker 的 persistCliSessionIdworker.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 restart IPC 的拒绝方向正确,但 /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。

没有申晗确认前不合码。

@deepcoldy

Copy link
Copy Markdown
Owner

Claude delta review — 新提交 b6bc26ff1「补齐关闭提示并拒绝无效重启」

针对我首审的 🟡P2(i18n key 缺失)和 🟡P3(restart 假成功),作者推了一个新提交。结论:P2/P3 已正确修复且测试到位;但三个阻塞项完全未动、仍然成立。

Delta 范围:09b96ef5..b6bc26ff1 = 18 文件 +242/−11(8 src + 10 test)。未触碰 shutdown-detach 协议 / session-store 批量 CAS / shutdown-budgets / daemon.ts / worker.ts —— 所以这是纯粹的 P2/P3 修复,不影响也不修复任何阻塞项。

✅ P2 修复(i18n key 缺失)— 已验证

  • worker.riff_close_in_progress 补进了 zh.ts + en.ts
  • 跑编译产物实测:
    • zh → ⏳ Riff 远程会话正在关闭,请等待关闭结果后再发送消息。
    • en → ⏳ The remote Riff session is closing. Wait for the close result before sending another message.
  • 新增行为测试 riff-explicit-close.test.ts:「shows a localized close-in-progress notice instead of leaking the i18n key」——正好断言我首审复现的那个字面量泄漏不再发生。

✅ P3 修复(restart 假成功)— 已验证,且是防御纵深

不只堵了我列的 4 个入口,还多堵了第 5 个(崩溃自动重启),并同时隐藏 UI 入口:

  1. /restart 命令 → isRiffBackendSession(ds) → 回 cmd.restart.riff_unsupported 引导语,不发 restart IPC / 不 killWorker
  2. Dashboard IPC → 返回 HTTP 409 {ok:false, error:'riff_restart_unsupported', message}(不再是假 200),前端 alert 现在优先显示友好 message
  3. 飞书卡片按钮 → stale 卡片点击也回引导语(deliverEphemeralOrReply)。
  4. card-builder.ts → 新 riff 卡片不再渲染重启按钮(effectiveCliId !== 'riff')。
  5. sessions.ts canRestartSession → dashboard 也隐藏 riff 的重启按钮。
  6. 崩溃自动重启(worker-pool claude_exit)→ 新增 riff 分支,在 crash-loop 计数之前拦截,不再发注定 no-op 的 restart IPC;且当会话处于 retirement phase(/close 或 shutdown 已接管生命周期)时不重复发引导语——这个去重细节做得好。

新增文案 cmd.restart.riff_unsupported(zh/en)实测解析正常。

⭐ 测试质量明显提升(回应我首审的批评)

首审我指出原测试是「源码文本断言」(indexOf 比字符串位置,只证 guard 存在、不覆盖 daemon 侧后果)。这批新测试是行为测试

  • command-handler.test.ts:断言 workerSend 未被调用killWorker 未被调用、用户收到引导语。
  • dashboard-ipc.test.ts:断言 HTTP 409 + 正确 error/message + send/forkWorker 均未被调用
  • crash-loop-diagnostic.test.ts:「does not auto-restart a crashed Riff worker」+「does not duplicate recovery guidance while an explicit Riff close owns the exit」(覆盖 retirement-phase 去重)。
  • persistent-backend-type.test.tsisRiffBackendSession freeze-once 语义(live worker stamp 优先于 stale 持久化 backend;restored worker 回落持久化 backend)——正确用会话 stamp 而非 bot 可变配置,避免改 bot 后让存量 riff generation 看起来「本地可重启」。

本地实测:pnpm build ✅ / tsc --noEmit ✅ / 相关 12 文件 639 tests 全绿

🔴 仍然成立、本提交未触碰的阻塞项

  1. P1 supervisor 超时(28s 预算 vs pm2 3.5s / restart 5s 轮询 → 孤儿 worker)——由 fix(pm2): add generation-safe fleet shutdown protocol #599 补齐,本提交未动。
  2. admission lease 空转 → continuation refork 逃逸——由 fix(codex-app): make turn ownership and recovery durable #597 补齐(codex 复现)。
  3. batch CAS 被旁路无锁写回滚——由 fix(codex-app): make turn ownership and recovery durable #597 补齐(codex 双进程复现)。

其它

净结论不变:P2/P3 已消除,#598 仍不是可独立上线的原子单元——硬顺序 #597 → rebase #598#599 → live 依旧。仍不合码,待申晗拍板。

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 上重新核对,仍原样成立

  1. #597 admission 前置仍缺失srcwithBotTurnAdmission 生产调用者仍为 0;只有 gate 自身两处。shutdown 的 mutation lease 仍无法 drain 已进入的异步 handler,commit 后 continuation refork 逃逸窗口未变。
  2. #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。 未经申晗确认不合码。

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.

2 participants