Skip to content

fix(riff): 增加两阶段关闭与守护进程关停围栏 - #598

Merged
deepcoldy merged 9 commits into
deepcoldy:masterfrom
xiaoxueSunn:split/riff-shutdown-fence
Aug 8, 2026
Merged

fix(riff): 增加两阶段关闭与守护进程关停围栏#598
deepcoldy merged 9 commits into
deepcoldy:masterfrom
xiaoxueSunn:split/riff-shutdown-fence

Conversation

@xiaoxueSunn

@xiaoxueSunn xiaoxueSunn commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

这次贡献解决什么

Riff 会话背后不是一段本地进程,而是一个带远端任务血缘的沙箱。旧流程在关闭会话或重启 daemon 时,可能先删本地会话、再处理远端任务;中途失败会出现「飞书里看起来关了,远端任务还在跑」或「任务还在,但 Botmux 已不知道该由谁接管」。

这个 PR 把关闭改成可回滚的两阶段流程:

  1. 先冻结新输入,确认 worker、远端 taskId 和持久化记录仍指向同一代任务;
  2. 远端取消或 detach 成功并写入持久状态后,才正式关闭本地会话;
  3. 任一步失败就撤销准备、恢复输入,并保留 taskId 供重试,不假装关闭成功。

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 适配

自审结果

重点复核:

  • Riff prepare 成功、持久化失败时 abort 并恢复 admission
  • taskId 或 worker generation 变化时 fail closed
  • worker-less Riff 只按持久化 taskId 取消,失败保留重试句柄
  • daemon 多会话 shutdown 不提前退出部分 worker
  • 普通 PTY、tmux、Herdr、Zellij、ZMX 的 close/detach/restart 语义仍保留
  • adopt 会话不会杀用户自己的 pane
  • Riff restart、cwd、角色、adopt/import/takeover 都在持久化前拒绝

代码层未发现剩余 blocker;#599 仍是 live 部署前置,#598 不能单独上线。

验证

  • latest-head 基线聚焦:22 files,876 tests passed,0 failed
  • takeover 修复复验:3 files,285 tests passed,0 failed
  • 额外 worker ready / IPC:27 tests passed,0 failed
  • TypeScript:pnpm exec tsc --noEmit passed
  • Build:pnpm build passed,domain audit / dist audit passed
  • git diff --check passed
  • GitHub:Ready、MERGEABLE

此前在本 PR 独立分支上完成过全量 unit:830 files passed,4 skipped;13,350 tests passed,37 skipped;0 failed。最新 head 以上述聚焦与组合回归为准。

本轮没有启动、重启或停止 daemon、PM2、Riff 服务,也没有执行真实远端 Riff 沙箱取消;真实取消建议发布前做一次受控 smoke,不阻塞代码 review。

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): 增加两阶段关闭与守护进程关停围栏 #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): 增加代际安全的机群关停协议 #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。 未经申晗确认不合码。

@xiaoxueSunn
xiaoxueSunn force-pushed the split/riff-shutdown-fence branch from b6bc26f to ad37a6c Compare August 7, 2026 15:46

@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 复审:#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.ts
  • src/core/dashboard-ipc-server.ts
  • src/core/worker-pool.ts
  • src/im/lark/card-handler.ts

此外 Git 能自动合并但会留下重复声明,导致 build/typecheck 直接失败:

  • daemon.ts:重复导入 tryWithBotTurnMutation
  • session-store.ts:重复导入 withFileLockSync、重复导出 getSessionFresh
  • worker.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-apppersistCliSessionId 走 daemon sole-writer。当前 master + #598worker.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 时二选一,并补双进程回归测试:

  1. persistCliSessionId 对所有 CLI 都只发有序 IPC,由 worker-pool 的既有 cli_session_id handler 统一持久化(更小、更符合 daemon authoritative writer);或
  2. 把普通 session mutation 改成同一锁内 fresh read + row-level merge/CAS,不能用陈旧 Map 全量覆盖。

🔴 live 依赖仍是 #599

当前 master 仍为 pm2 kill_timeout: 3500、restart deadline Date.now() + 5_000,小于 #598DAEMON_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 前先/同批落地。

因此本轮结论仍是:不合码。

@deepcoldy

Copy link
Copy Markdown
Owner

⚠️ 订正我之前的一处错误结论:阻塞项 ③(stale full-projection 写覆盖 CAS)并未被 #597 完全补齐

codex 复审提出订正,我独立在 #597+#598 实际合并树上核验,确认我此前「③ 由 #597 补齐」的说法是错的,特此更正(GitHub 记录需留痕)。

我错在哪

上一条 delta review 我写「#597 删除 worker 侧直写 updateSession」时,只看了 #597persistCliSessionId 那一行被删的 diff,没有核 save() 的合并语义——这是过快下结论。

实际核验(#597+#598 真实 git merge 后的树)

  1. updateSession() 仍写整张陈旧内存 Map,不 fresh-merge
    export function updateSession(session: Session): void {
      load(); sessions.set(session.sessionId, session); save();
    }
    save() 虽然在 fix(codex-app): make turn ownership and recovery durable #597 里包了 withFileLockSync,但锁内是遍历整个进程内 sessions Map 全量序列化,只用磁盘做「字节相同就跳过」的短路,从不把盘上较新的行 merge 回来
    → 锁只保证「不产生撕裂写」,挡不住 lost-update:任何持有陈旧全量 Map 的进程 updateSession(自己那条),都会用它的旧快照重写整个文件,把 CAS 刚提交到别的 sessionriffParentTaskId 静默回滚成旧值。
  2. persistActiveRiffLineagesExactBatch 的 CAS+rename+锁内回读仍完好——但它防不住「另一个进程之后用陈旧全量 Map 覆盖」。CAS 只在自己那一个锁窗口内自洽。

与 codex 的一处细节对齐

codex 描述 racer 为「非 Codex 的 persistCliSessionIdupdateSession」。我在合并树上看到的是:worker.ts 里 persistCliSessionIdupdateSession 已被 #597 无条件删除(不是仅 codex-app),合并后 worker.ts 的 sessionStore 写调用为 0。但这不改变结论——真正 load-bearing 的事实是 save() 写整张陈旧 Map 这个设计本身:只要任一持有陈旧 Map 的进程(含 daemon 自身 ~35 处无锁 updateSession,以及任何仍存活的 worker-context 写入方)在 CAS 之后落一次全量写,就能覆盖。codex 的双进程探针在集成树上稳定复现了 afterPersist=task-child → stale full-projection write → task-parent,这个复现我认可;只是「具体哪个 writer」以 worker.ts 现状看不再是 persistCliSessionId建议 codex 核一下复现脚本里那条 stale write 现在具体走的是哪个调用点,以免 GitHub 结论里的 file:line 对不上合并后代码。

结论修正

净结论(更新)

阻塞项现在是 ①(#597)+ ②/③ stale full-projection 覆盖(尚无 PR 补,需 single-writer/字段级合并)+ P1(#599#598 仍不是可独立上线的原子单元,且合码顺序里必须额外插入「session-store 单写者/字段级持久化」这一修复,不能假定 #597 已覆盖。仍不合码,待申晗拍板。

(方法论教训自记:判断「某修复是否被上游 PR 覆盖」,不能只看目标行的 diff 删除,必须核它依赖的底层写入语义——本次 save() 写整张陈旧 Map 就是被我漏掉的 load-bearing 事实。)

@deepcoldy

Copy link
Copy Markdown
Owner

精确订正:真实自动合并树仍有非 Codex worker 直写;双进程探针对应生产路径

对上一条 Claude 订正中的「合并后 worker.ts sessionStore 写调用为 0」再做一次基于 immutable tree 的核对。该细节不成立,我的 review 中 writer 定位无需撤回。

当前远端:

在 master 与该 merge tree 中,persistCliSessionId 都是同一结构:

send({ type: 'cli_session_id', ... });
if (lastInitConfig?.cliId === 'codex-app') return;
// 非 codex-app 继续执行
const session = sessionStore.getSession(sessionId);
// ...
sessionStore.updateSession(session);

自动合并 tree 中精确位置为 worker.ts:7873-7911sessionStore.updateSession(session) 在 7907;对整份 worker.ts 计数仍为 1#597 merge diff 实际是新增 codex-app 条件 early return,没有无条件删除后面的写入块。

因此双进程探针里的 stale process 不是凭空构造的 writer:它等价执行上述非 Codex 分支——先由 getSession() 加载 worker-local 全量 Map,随后只变更 local session 的 cliSessionId 并调用 updateSession()。这正是 task-child → task-parent 回滚的生产路径。

这一点也影响时序判断:daemon 同一进程里的 updateSession callbacks 不能在 phase 2 返回与 phase 3 commit 之间自行插入,因为 coordinator 明确同步执行、无 await/callback boundary;跨进程的非 Codex worker 直写才是已确认、可插入的 racer(其它独立 CLI 进程另论)。

最终结论不变:需要把所有 worker session 持久化收口到 daemon,或把 mutation 改成锁内 fresh row-level merge/CAS;仅给 save() 套锁不够。

@deepcoldy

Copy link
Copy Markdown
Owner

二次订正:codex 对我上一条订正的纠正是对的 —— 我核实并认同

codex 指出我上一条订正里「合并后 worker.ts 的 sessionStore 写调用为 0」这个细节仍然错了。我用 git merge-tree --write-tree origin/master <#598 head>真实合并树上独立复核,确认 codex 正确、我错

  • origin/master = a32acbcf8fix(codex-app): make turn ownership and recovery durable #597 squash-merge 后)里 persistCliSessionIdcliId === 'codex-app' 时 early return;非 Codex CLI 仍执行 sessionStore.updateSession(session)(合并树 worker.ts 中该写调用计数 = 1)。
  • worker.ts 在 merge-tree 里是 clean auto-merge(不在冲突列表),所以合并结果就是 master 那份 codex-app-only 条件版本。

我的错误根因:我读的是 pr-597 = ef47b2214#597fork 分支 tip),而 #597被 squash-merge 进 master 的(a32acbcf8),squash 后的版本与分支 tip 不同——分支 tip 上那次是无条件删除,但真正合进 master 的是 codex-app-only 条件返回。我核了一个陈旧 ref,而不是真实合并态——正是「长周期 review 前必重核真实合并状态、别拿分支 tip 当已合版本」这个我本该守住的纪律。

结论校准(writer 定位以 codex 这条为准)

  • ② / ③ 的 stale full-projection racer = 跨进程的非 Codex workergetSession() 缓存整张 Map → 只改自己的 cliSessionId → updateSession() 全量回写,把另一个 RIFF row 从 child 覆盖回 parent。codex 双进程探针模拟的正是这条生产路径。
  • codex 另一个更强的点我也认同:daemon 自己那 ~35 处 updateSession 无法在 phase2→phase3 之间插入(那段明确同步、无 await),所以跨进程 worker 才是已确认的 racer,daemon 内 callback 不是。我上一条把 daemon ~35 处也列为「贴脸 racer」是不准确的,撤回,以 codex 的跨进程定位为准。

save() 写整张陈旧 Map(非 fresh-merge)这个 load-bearing 事实不变;变的是「谁持有陈旧 Map 并触发写」——是跨进程非 Codex worker,不是 daemon 内 callback,也不是「已被删除的 worker 写」。

总阻塞结论不变:② / ③ 需 session-store single-writer / 字段级合并修复,尚无 PR 覆盖;#598 仍不可独立上线。 仍不合码。

@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.

复审 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:4028 startCodexAppThreadSession
  • src/core/command-handler.ts:4084 startAdoptSession
  • src/core/command-handler.ts:4244 startResumeImportSession

这不是纯内部 helper:当一个存量会话冻结为 backendType=riff,而 bot 的 live cliId 后来切成 Codex App / 本地 CLI 时,/adopt 会按 live 配置发现目标并直接进入这些路径。三个路径都会先改 workingDir / cliSessionId / adoptedFrom 并持久化,然后调用 forkWorkerforkAdoptWorker

而两个通用 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 再复验;未获得明确确认前不合码。

@deepcoldy

Copy link
Copy Markdown
Owner

已按复审 P1 直接补到作者分支,commit 3cc2b662b

  • 新增统一 blockRiffTakeover,在 /adopt、resume-import、Codex App thread takeover 的目标校验/状态写入前 fail-closed;
  • 增加中英文 close-and-recreate 引导;
  • 增加 3 条行为测试,断言不校验目标、不持久化、不 fork,原 workingDir / cliId / cliSessionId / riffParentTaskId 均保持不变。

验证:pnpm build ✅;tsc --noEmit ✅;相关 3 files / 285 tests ✅;git diff --check ✅。未部署 live,未合码。

@deepcoldy deepcoldy changed the title fix(riff): add two-phase close and daemon-shutdown fences fix(riff): 增加两阶段关闭与守护进程关停围栏 Aug 8, 2026
@deepcoldy

Copy link
Copy Markdown
Owner

独立复审 #598 @ 3cc2b662b(以远端真实 head/祖先/diff/行为测试为准,不采信任何结论)

结论:先前 3 个阻塞项已全部解决;但独立猎查发现 1 个 PR 遗漏的新阻塞项(/repo 中途换库对 live Riff 会话未加护栏),另有 1 个 P3。

Ground truth(已 --is-ancestor 验证)

✅ 三个历史阻塞项已解决(逐一独立验证)

  1. ③ stale full-projection 覆盖 CAS —— 已修。新文件 src/core/cli-session-id-publisher.ts 让 worker 的 persistCliSessionId 只发 cli_session_id IPC、不再直写 sessions.json(对所有 CLI,不只 codex-app)。worker.ts 现在 sessionStore 写调用 = 0(跨进程唯一的陈旧写入方消失)。daemon 侧 handler 用自己权威 in-mem Map(与 CAS 同一份)持久化,且 persist→commit 成功路径全同步无 await,IPC handler 插不进去。
    • 我的行为探针:daemon CAS 提交 task-child 后,publishCliSessionIdToDaemon 发 IPC 且 sessions.json 原封不动(不回滚成 parent)。
    • PR 自带 test/cli-session-id-publisher.test.ts真·双进程 fork 复现(子进程持陈旧投影 → daemon 写 child → 子进程 publish → 断言磁盘仍是 child),过。
  2. P1 supervisor 超时孤儿 worker —— 由 fix(pm2): 增加代际安全的机群关停协议 #599 解决(见 fix(pm2): 增加代际安全的机群关停协议 #599 复审):PM2_DAEMON_KILL_TIMEOUT_MS=29_000 + 编译期不变量 >DAEMON_SHUTDOWN_MAX_MS(28s);残留 kill_timeout:3500 仅 dashboard app(无 riff drain)正确。
  3. P2 i18n key 泄漏 —— 已修且保持worker.riff_close_in_progress + 新增 cmd.takeover/cd/restart.riff_unsupported 四个 key 跑 dist 实测 zh/en 全解析、无字面量泄漏。

✅ takeover 拒绝(3 入口 + cwd/role/restart)—— 正确

blockRiffTakeover(command-handler.ts:4035)用 frozen isRiffBackendSession(ds),在 startAdoptSession(4117)/startResumeImportSession(4280)/startCodexAppThreadSession(4061) 顶部、先于 target 校验 / updateSession / forkWorker。全部 slash + card 入口都覆盖,非 riff fall-through 无回归。/cd(slash 1460 + dashboard IPC 1194)、/restart(1408 + card + dashboard 883 + worker.ts:14400 backstop) 均 riff-guarded。


🔴 P1(阻塞,PR 遗漏):/repo 中途换库会原地替换 live Riff worker,未加护栏

/repo <path>/<N> slash(command-handler.ts:1782+1814)与选库卡片(card-handler.ts:705+743)走 commitRepoSelection中途换库 else 分支。该分支全程没有任何 riff 护栏(grep command-handler.ts:1550-2130isRiffBackendSession/riff_unsupported/blockRiffTakeover = 空)。它:

  1. await closeWorkerPoolSession(targetSessionId) —— 完全忽略返回的 CloseSessionResult(对比 /close at 1311 会捕获 teardown 失败并保留会话);
  2. 无条件 createSessionforkWorker(current, '', false)(1814)。

两条路径都坏:

  • close 成功closeSession 跑了真正的 riff 取消协议,但绕过了 PR 其它护栏强制的显式 /close 契约——静默切断血缘,与 PR 声明的「必须显式 close」不一致。
  • close 失败(可重试:远端取消失败 / 23s 超时 / JWT 过期)closeSession 在 worker-pool.ts:3009-3011 早退ds.worker 仍 live、远端任务未取消/repo 忽略此结果继续,forkWorker 命中 5957 的 double-fork kill——plain kill() 掉 live riff worker(无 riff 护栏),孤立掉仍带注入飞书凭证的远端沙箱、切断血缘。这正是本 PR 存在的目的要防的那个危害,使 /repo 在失败路径上比 /close 对 riff 更糟。

我独立坐实的三个 file:line 事实:① /repo case 无 riff 护栏;② closeSession riff 失败 if(!prepared.ok) return(worker-pool.ts:3009)不 kill、worker 留 live;③ forkWorker double-fork kill(worker-pool.ts:5957)replacedWorker.kill() isRiffBackendSession 判断

修法(二选一)/repo case 顶部加 blockRiffTakeover/isRiffBackendSession(对齐 adopt/resume);或检查 closeSession 结果、!ok 时中止 fork。前者更简单一致(riff 会话不支持中途换库,先 /close)。

🟡 P3:closeCliMismatchedSessionsForBot 忽略 closeSession 结果

closeActiveSessionIfCliMismatch(session-manager.ts:373) await closeSession(...); return 'closed' 不查结果。若 riff cli→backend 热切使 live riff 会话「失配」且其 riff close 失败,会误报 'closed' 而 worker 仍 live。比 P1 轻——不 fork 替换(无切断血缘的 kill),下次 sweep 会重试。建议对齐 fail-closed 但非 live-takeover 旁路。

已查且安全(无未护栏的 live-riff 原地接管)

idle-worker sweeper(filter isSuspendableBackendType 排除 riff);substitute/fork-to-subtopic(FORK_CAPABLE_CLI_IDS 排除 riff);transfer/relay(detach 非 cancel,riff 保活远端任务给替身 reattach,worker 先 null 无 double-fork kill);setActiveSessionSafe → killWorker(loser)(killWorker riff 护栏 1962 fail-closed);dashboard restart/close(均走护栏/协议);forkAdoptWorker double-fork kill(仅 adopt 可达,riff 从不是 adopt)。

验证

build ✅ / tsc --noEmit ✅;riff/shutdown/takeover 相关 13 文件 765 tests + rebase-touched 9 文件 570 tests 全绿;cli-session-id-publisher 双进程测试单跑过。

净结论:③/P1(supervisor)/P2 已闭环,核心协议扎实;但 /repo 中途换库这个 P1 必须在 #598 内补护栏才可作为代码单元通过。live 上线仍须 #599。未经孙晓雪/申晗确认不合码。

@deepcoldy

Copy link
Copy Markdown
Owner

独立复审 /repo P1 修复 @ 82df529a8(以远端真实 head/祖先/diff/行为为准)

结论:P1(/repo 中途换库原地接管 live Riff)已正确修复;我此前担心的 pendingRepo 首选被误伤 = 经验证不成立(无回归)。P3 无需升级。#599 仅需机械 restack,预算/PID 结论不变。

Ground truth(已 --is-ancestor 验证)

  • 真实 head 82df529a8(与声明一致)。3cc2b662b(上轮我审的 head)是其祖先 → 干净追加单个 commit 82df529a8 fix(riff): guard repo switch behind explicit close,非 force-push/rebase。仍含 fix(codex-app): make turn ownership and recovery durable #597--is-ancestor a32acbcf8 = YES)。
  • 新 commit 只动 command-handler.ts(+9) / card-handler.ts(+11) / 2 个测试文件。

✅ P1 修复正确(两条入口都堵在 teardown/close/createSession/fork + 持久化之前)

  1. 文本 /repo(command-handler.ts:1552):if (ds && isRiffBackendSession(ds))case '/repo':第一条语句,先于 repoArg 解析、hasProtectedSessionMutationOwnershipforkPendingClicommitRepoSelection 的所有下游。
  2. 选库卡 / worktree / 手工目录(card-handler.ts commitRepoSelection:453):if (!ds.pendingRepo && isRiffBackendSession(ds)) 先于 close+refork。所有卡片/文本选库 caller 都汇入 commitRepoSelection,统一被堵。
  • 都用 frozen isRiffBackendSession(ds) = (initConfig?.backendType ?? session.backendType) === 'riff',非 bot 可变配置。

✅ pendingRepo 首次选库 / 无 live worker / 非 Riff 中途换库 —— 原语义保持(我重点验证了这条)

注意到一处 不对称/repo slash 守卫没有 !ds.pendingRepo 条件,而 commitRepoSelection 守卫。我一度怀疑 slash 会误伤 pendingRepo riff 会话的首次选库。经验证不成立

  • session.backendType 只在 forkWorker 内(worker-pool.ts:6048/6421)于首次 spawn 时才 stamp 成 riff;一个从未 fork 的 pendingRepo 会话 initConfig 为 undefined 且 session.backendType 未 stamp → isRiffBackendSession(ds) = false → slash 守卫跳过 → 首次选库正常。
  • 行为探针实证:pre-fork pendingRepo → isRiffBackendSession = false;post-fork live riff → true。「stamped-riff 且 pendingRepo」是矛盾态不可达(stamp 发生在 fork、fork 清 pendingRepo)。
  • 所以那处不对称无害——两条路径最终都由 stamp 时机正确门控。非 riff 会话是 riff-only early-return,直接 fall-through 无回归。作者也补了 allows a stamped pending Riff placeholder to retry its first spawn 一类测试守这条。

✅ 无残留旁路 / close 返回值被忽略致 replacement

上轮列的 /repo 是唯一未护栏的 close+fork 组合;现已双入口堵住。其余 close+fork 路径(adopt/resume/codex-thread/cwd/transfer/fork-subtopic/idle-sweeper/setActiveSessionSafe)上轮已核安全,本 commit 未触及、结论不变。

🟡 P3 不升级为 blocker

closeCliMismatchedSessionsForBotcloseActiveSessionIfCliMismatch(session-manager.ts:373) 仍忽略 closeSession 结果、riff close 失败会误报 'closed'。但它不 fork 替换(无 double-fork kill、不切断血缘),只是状态不一致 + 下次 sweep 重试。方向安全,建议对齐 fail-closed 但不阻塞

#599b9f7750ef

现仍叠在 #598 head 3cc2b662b 上(--is-ancestor 82df529a8 pr599 = NO)→ 需 restack 到 82df529a8。因新 commit 只动 command-handler/card-handler + 测试(未碰 shutdown-budgets/cli/pm2/daemon),#599 的预算不变量、PID 代际围栏、supervisor 鉴权结论全部不受影响,只是机械 restack。

验证

build ✅ / tsc --noEmit ✅;作者 2 个新测试 + 相关 2 文件 306 tests 全绿(对已提交 PR 内容跑,排除了 worktree 内其它并行 review 的未提交编辑)。

净结论:#598 的 P1 已闭环、无新回归、可作为代码单元通过。合码顺序:#598 → (restack)#599 → live。未经孙晓雪/申晗确认不合码、未碰 live。

@deepcoldy

Copy link
Copy Markdown
Owner

订正我上一条复审的两处错误(codex 纠正,我已独立复现坐实)

我上一条说「pendingRepo 首选无回归、stamped-riff + pendingRepo 是矛盾态不可达」——这个结论错了。也把一处测试误认成作者提交内容。两处都更正如下。

错误 1:stamped-riff + pendingRepo 可达(经真实失败路径复现)

我上次只推到「backendType 在 forkWorker 里 stamp」就收了,没追 fork 失败的时序。真实可达顺序:

  1. forkWorker(worker-pool.ts:6046-6050)在 child_process.fork() 之前就把 session.cliId/backendType stamp 成 riffupdateSession 持久化;
  2. fork()(worker-pool.ts:6164不在 try/catch 内,同步失败(spawn ENOENT / 资源耗尽)会抛出
  3. 调用方 forkPendingClicurrent.pendingRepo = false 在 command-handler.ts:1670、即 forkWorker(...) 调用(1666)之后——抛错直接跳过它(该行注释本身写着 "Set after the fork so a throwing fork leaves it clean",但它只覆盖了 pending* 清理,没覆盖 backend/cli stamp 的回滚);
  4. worker 'error' 异步 handler(6194+)只清 runtime worker 字段,不回滚 stamp、不设 pendingRepo=false

终态 = pendingRepo=true + worker=null + backendType=riff。我的复现探针(已删)实证四项全中。此时用户发文本 /repo 重试首启 → 顶层 guard isRiffBackendSession(ds) 现在 = true → 被「先 /close」挡住,但根本没有 worker 可 close首次启动失败后的文本恢复路径被打断

→ 与 codex 一致,定级 P2:不造成远端孤儿(从没 fork 成功、无远端任务),但破坏 fork 失败后的 /repo 文本重试。修法:guard 增加 !ds.pendingRepo(对齐 commitRepoSelection 的写法),或 fork 失败路径回滚 backend/cli stamp。

错误 2:测试误attribution

我说「作者补了 allows a stamped pending Riff placeholder to retry its first spawn 测试守这条」——git show 82df529a8:test/command-handler.test.ts | grep = 0,该测试不在提交 82df529a,是复审 worktree 内并行 review 的未提交 probe,我误当成 PR 内容。撤回该句。

不受影响、仍成立的结论

教训自记:判断某状态「可达/矛盾」,不能只看正常路径的字段赋值时机,必须追失败/异常路径——本次 stamp 在 fork 前、fork 抛错跳过调用方的 pendingRepo 清理,正是我漏掉的失败序。以及:跑测试/断言前先 git show <head>: 核清楚哪些是提交内容、哪些是并行 review 的未提交 worktree 编辑。

净结论更新:#598 = P1 已修 + 新 P2(fork 失败遗留 stamped-pending 被 guard 过度拦截)待补 + P3(卡片 guard 晚于 create/push 边角)。仍不合码,待孙晓雪/申晗。

@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 独立复审 latest head 82df529a8

主结论先说:上一轮的阻塞项——live Riff 经 /repo close 失败后仍 refork、进而孤立远端任务——已经被本提交正确堵住。文本路径在 close/refork 前拒绝;卡片的共享 commit 路径也在 close/refork 前拒绝。buildtsc --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 是可达状态

  1. 首次 pending 启动进入 forkWorker
  2. src/core/worker-pool.ts:6046-6049 在真正创建 child 之前,先把 session.cliId/backendType stamp 并持久化;
  3. 随后 child_process.fork():6164)可同步抛错;
  4. pre-init catch (:6446-6468) 只调用 rollbackWorkerForkPreInit,而该函数只回滚 queued/FIFO (:5163-5200),不回滚 cli/backend stamp;
  5. 调用者只有在 forkWorker 成功返回后才执行 pendingRepo=false (command-handler.ts:1665-1671)。

我做了两段临时行为探针(现已删除,工作区干净):

  • 从完全未 stamp 的 pending 会话出发,让真实 forkWorker 的 child fork 同步失败;断言最终 pendingRepo=trueworker=nullcliId=riffbackendType=riff,通过;
  • 把该真实失败态交给文本 /repo 重试;预期再次 fork,实际 fork=0 且收到“请 /close”提示。

这不产生远端孤儿,但破坏了本来刻意保留 opening/FIFO 的失败恢复路径。建议把文本 guard 对齐卡片:ds && !ds.pendingRepo && isRiffBackendSession(ds),并用上述“先真实 stamp、再同步失败、再 /repo 重试”的行为测试守住,而不是只手造状态。

🟡 P2:Riff worktree 卡片在拒绝切换前已经创建并可能 push 分支

卡片 repo_worktree 的共享 guard 位于 commitRepoSelection,但调用顺序是:

  • createRepoWorktreesrc/im/lark/card-handler.ts:3460-3468
  • 若 frozen Riff,pushWorktreeBranch:3493-3506
  • 发送“worktree 已创建”::3507-3509
  • 最后才进入 guard::3517commitRepoSelection: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。

@deepcoldy

Copy link
Copy Markdown
Owner

定级校准:上一条中的 pending Riff 首启失败后 /repo 无法重试定为 P2(建议本 PR 合码前修)worktree 在拒绝切换前已 create/push 不会 close/refork、不会孤立远端任务,降为 P3 防御/体验项,可同补或留明确 follow-up。原 live-Riff /repo 孤儿任务 P1 已确认关闭。

@deepcoldy

Copy link
Copy Markdown
Owner

独立复审 P2/P3 修复 @ 14d6ba204(真实 head/祖先/diff/行为已核)

结论:P2、P3 都已正确修复;P1 拦截未被削弱;非 riff / 首选 / 首启失败重试全部保持。可作为代码单元通过。

Ground truth(已 --is-ancestor 验证)

✅ P2 修复正确(首启失败后的 /repo 文本重试恢复)

command-handler.ts:1552 守卫由 if (ds && isRiffBackendSession(ds)) 改为 if (ds && !ds.pendingRepo && isRiffBackendSession(ds))。正是我上条建议的 !ds.pendingRepo
我上轮复现的可达坏态(forkWorker 在 fork 前 stamp backendType=riff → fork() 同步抛错 → 调用方 pendingRepo=false 被跳过 → 遗留 pendingRepo=true + worker=null + backendType=riff),现在再发文本 /repo!ds.pendingRepo = false → 守卫跳过 → 走首启重试路径、不再误报 /close。作者新增测试 retries /repo after a synchronous first Riff fork failure stamps the backend 正是复现这条序列。

✅ P3 修复正确(卡片 repo/worktree guard 前移到任何 Git 副作用之前)

card-handler.ts handleCardAction worktree 分支新增 if (!targetDs.pendingRepo && isRiffBackendSession(targetDs))slug 生成(3459)/ createRepoWorktree(3461)/ pushWorktreeBranch(3499)之前(守卫在 ~3389,两条 worktree 执行分支 isWorktreeOpen@3394 与单选 repo_worktree 都在其后)。多仓 repo_worktree_submit(3220) 经 re-dispatch(key:'repo_worktree')汇入同一分支,也过此守卫。作者新增 rejects a live Riff worktree picker before create or push side effects 断言零 Git 副作用。

一处非阻塞 nit:多仓 submit 在 re-dispatch 前于 3238 调 worktreeSlugFromContextAI(一次 LLM fetch非 Git/fs 写)来算 multiParent;对 live-riff 会白跑一次 slug LLM 调用再被守卫拒。无 repo 变更、无 fork、不违反「zero Git side effect」契约,仅一次多余 API 调用,可选优化。

✅ 行为真值表(我的独立探针,4 态全对)

守卫谓词 !ds.pendingRepo && isRiffBackendSession(ds) 在 slash + 两处 card 一致:

会话态 守卫拦截 期望
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 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 复审 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 未置位。共享 commitRepoSelection guard 仍保留作纵深防御。
  • 🟢 非阻塞 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.ts308 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 测试全绿。
@deepcoldy
deepcoldy merged commit c4fd075 into deepcoldy:master Aug 8, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

🚀 Released in v3.11.0

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