Skip to content

fix(pm2): 增加代际安全的机群关停协议 - #599

Merged
deepcoldy merged 6 commits into
deepcoldy:masterfrom
xiaoxueSunn:split/pm2-fleet-shutdown
Aug 9, 2026
Merged

fix(pm2): 增加代际安全的机群关停协议#599
deepcoldy merged 6 commits into
deepcoldy:masterfrom
xiaoxueSunn:split/pm2-fleet-shutdown

Conversation

@xiaoxueSunn

@xiaoxueSunn xiaoxueSunn commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

这次贡献解决什么

PM2 管理的是一整组 Botmux daemon。旧的 stop / restart 主要按进程名发信号、再修改 PM2 registry;当 daemon 正在做 Riff 的 prepare / persist / commit 关闭协议时,如果 PM2 提前 SIGKILL、自动拉起同名 successor,或 CLI 读到过期的 pm2 jlist,就可能出现「旧进程还没安全退完,新一代已经起来」或「信号死亡被误认成正常退出,PM2 不再拉起」的问题。

这个 PR 把整机停启改成代际安全的协议:

  1. 只向 descriptor、PID、进程出生时间都匹配的 daemon 发认证 shutdown 请求;
  2. 等 daemon 完成 Riff fleet prepare / persist / commit,并以 PM2 managed graceful sentinel 退出;
  3. 重新读取 PM2 registry,确认原 PID 已终止且没有未证明的 autorestart timer;
  4. 只按精确 PM2 id 启动缺失进程,并等待 successor 稳定;
  5. 任一步无法证明时停止 registry mutation,保留现场并报错,不把不确定状态当成功。

依赖与当前状态

关键安全边界

  • 旧 PID / 重用 PID / 同名 successor 不会被当成原代 daemon
  • signal-only exit 0 不会被误当成协议完成
  • 不完整 stop_exit_codesautorestart=false 会阻止启动/重启事务
  • PM2 jlist 非法、重复行、过期 descriptor、未注册 live daemon 均 fail closed
  • 首次升级旧协议使用显式 bootstrap 门禁,不直接信号旧 daemon
  • plugin service 不进入 core fleet mutation
  • daemon、Dashboard、PM2 启动策略统一复用 BOTMUX_PM2_GRACEFUL_EXIT_CODE=90
  • worker、plugin 和本地终端子进程剥离该 sentinel,避免子进程误报 supervisor 级正常退出

影响面

  • 公共 CLI/PM2 fleet start/stop/restart 路径
  • daemon 与 Dashboard 的认证 shutdown IPC
  • restart intent / report 持久化
  • Riff 会话得到更长、可证明的安全关停预算
  • 不改变普通消息处理、CLI adapter 或非 PM2 运行方式

本次 restack 验证

  • pnpm build:passed(含 domain audit / dist audit)
  • pnpm exec tsc --noEmit:passed
  • PM2 / supervisor / Riff 聚焦:20 files,214 tests passed,0 failed
  • git diff --check:passed
  • git range-diff:后两提交补丁等价;首提交仅包含当前基线已有的 file-lock import 与 dashboard mutation guard 冲突合并

本轮没有启动、停止或重启真实 PM2、daemon、Riff 服务;未执行会改变本机 fleet 的真实 stop/restart smoke。#598 合入后转 Ready 前,再跑一次最新 master 回归。

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 force-pushed the split/pm2-fleet-shutdown branch from 06b2dc0 to 09dca6d Compare August 8, 2026 03:14
@deepcoldy
deepcoldy force-pushed the split/pm2-fleet-shutdown branch from 09dca6d to b9f7750 Compare August 8, 2026 08:35
@deepcoldy deepcoldy changed the title fix(pm2): add generation-safe fleet shutdown protocol fix(pm2): 增加代际安全的机群关停协议 Aug 8, 2026
@deepcoldy

Copy link
Copy Markdown
Owner

已将 #599 的 3 个 PM2 协议提交重放到修复后的 #598 head 3cc2b662b,新 head 为 b9f7750ef

冲突处理保留了 #597 的 dashboard protected-session mutation guard,并并入认证 supervisor shutdown route;range-diff 显示后两提交补丁等价,首提交仅有当前基线已有 import 与上述冲突合并差异。

验证:pnpm build ✅;pnpm exec tsc --noEmit ✅;PM2 / supervisor / Riff 聚焦 20 files / 214 tests ✅;git diff --check ✅。GitHub 当前 MERGEABLE

未执行真实 PM2/daemon/Riff stop/restart。PR 继续保持 Draft,等 #598 合入后去掉父 PR diff、再跑最新 master 回归并转 Ready。

@deepcoldy

Copy link
Copy Markdown
Owner

独立复审 #599 @ b9f7750ef(代际安全 PM2 机群停启)—— 无阻塞

结论:无 blocker 无 major。孤儿 worker 回归已闭环、PID 复用/后继代际 fail-closed 围栏、supervisor 路由双重鉴权、restack 干净。 以远端真实 blob(git show pr599:…)为准,非 commit message。

Ground truth(已验证)

git merge-base --is-ancestor pr598 pr599 = YES → #599 确实叠在 #598 head(3cc2b662b)之上。真实 diff = pr598..pr599 = 3 commit / +3340 src。GitHub: MERGEABLE / 仍 Draft。#599 diff 内 grep '^(<<<<<<<|>>>>>>>|=======)' = 空(无遗留冲突标记),tsc --noEmit = 0(无重复声明)。

1. 预算不变量 —— PASS

shutdown-budgets.ts:手算 DAEMON_SHUTDOWN_MAX_MS = 1000+1000+12000+1000+max(11000,3000)+2000 = 28000。四条 module-load throw 都在且成立:PM2_DAEMON_KILL_TIMEOUT_MS=29000 > 28000≤28000 上限;FLEET_DAEMON_EXIT_WAIT_MS=60000 > 29000> 31500> 32500。bot-daemon app 用 kill_timeout: PM2_DAEMON_KILL_TIMEOUT_MS(非字面量),唯一残留 3500 是 dashboard app(无 riff drain)正确。不变量自enforce:谁把 daemon 预算缩到 kill_timeout 以下会在 module load 抛错。

2. 旧「5s 无条件 delete」竞态 —— 已消除(这正是我首审 P1 的机制)

deleteAllBotmuxProcesses 现在 signalAndAwaitFleet(entries, op, FLEET_DAEMON_EXIT_WAIT_MS=60000)真实 OS 退出+后继静默窗口(远超 28s 优雅预算),然后每行 delete 前 revalidateExactQuiescentRowBeforeMutation live 则 fail-closed 抛错。原来那些 maxWaitMs:5_000 是 file-lock 获取超时、不是进程退出轮询。无路径在预算耗尽前 SIGKILL/删除仍在 draining 的 daemon。

3. PID 复用/后继代际围栏 —— PASS

身份绑进程出生非裸 PID:process-start-identity.ts 读 Linux /proc/<pid>/stat field 22 starttime(非 Linux 走 lstart/CreationDate)。pm2-descriptor-guard/pm2-shutdown-capability/supervisor-shutdown-client 每次授权前重读 start-identity 比对,不符即 fail-closed。fleet-shutdown.isFleetEntryProvenTerminalAfterSignal 拒绝把 PM2-normalized exit_code 0 当优雅退出(SIGKILL/OOM 与 0 无法区分)。首次升级 assertDaemonPm2GracefulExitPolicy 要求 autorestart=true+stop_exit_codes=[90] 否则 fail-closed 给操作员 bootstrap 指令。

4. restack 冲突语义 —— PASS

git diff pr598..pr599 -- dashboard-ipc-server.ts#597 protected-session rejectProtectedSessionMutation 守卫体 pr598 vs pr599 逐字节相同、4 个调用点仍在(未被 clobber);新增 supervisor route 有鉴权(见下);withFileLockSync 只 import 一次(复用基线)。

5. supervisor 路由鉴权 —— PASS(双重门,非任意本地进程可触发)—— 我亲自复核

POST /__supervisor-ipc/v1/shutdown handler(dashboard-ipc-server.ts:295 起)第一动作就是 if (!isTrustedHostIpcRequest(req)) return 403,再 503(not ready)、再 409(isExactSupervisorShutdownRequest 代际元组不匹配),三道门全过才 setImmediate(shutdown)——auth 先于任何副作用。且该路由不在 routeHasPublicAccess/routeIsCoreOnlyPublic。传输层另有 HMAC(~/.botmux/.dashboard-secret,对 bwrap 沙箱 mask→沙箱 CLI fail-closed)。能力 fence:pm2-shutdown-capability 要求目标持新鲜(90s mtime+语义心跳)descriptor 声明协议版本,daemon 最后才发布该能力。

6. 测试 —— PASS 161/161

fleet-shutdown(36)/pm2-start-transaction(22)/shutdown-supervisor-contract(19)/pm2-descriptor-guard(8)/pm2-shutdown-capability(6)/pm2-exact-start(10)/pm2-jlist(15)/其余 + restart-intent-store(15)/restart-report(17)/loopback integration(1)。tsc --noEmit=0。

非阻塞观察

  • readSupervisorProcessStartIdentity 非 Linux 走 ps/powershell(2s 超时/次);生产 Lindaemon 直读 /proc 无虑。
  • SIGTERM/SIGINT 失败 process.exit(1)(daemon.ts)正确——失败退出不是优雅 sentinel(90),PM2 会 autorestart 而非当干净退出。

净结论:#599 作为 #598 的 live 前置,代码质量高、无阻塞。它正是我首审 P1 的结构性修复。#598 的合码顺序:#598(补 /repo P1 护栏后)→ #599 → live。未经孙晓雪/申晗确认不合码、不碰 live daemon/PM2。

@xiaoxueSunn
xiaoxueSunn marked this pull request as ready for review August 8, 2026 17:07
@xiaoxueSunn
xiaoxueSunn requested a review from deepcoldy as a code owner August 8, 2026 17:07
@deepcoldy

Copy link
Copy Markdown
Owner

首审 #599 PM2 机群关停 @ 101ac2110(真实 head/祖先/diff/行为/对抗已核,不采信声明)

结论:无 blocker、无 major,1 个 minor(测试质量,非覆盖缺口)。可进第二轮复审。

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

✅ 预算不变量 + 所有 supervisor 入口(area 1)

  • shutdown-budgets.tsPM2_DAEMON_KILL_TIMEOUT_MS=29_000DAEMON_SHUTDOWN_MAX_MS=28_000(手算 1000+1000+12000+1000+max(11000,3000)+2000);5 条 module-load throw 守卫(38-52)——任何回退到旧 3.5s / 放大预算都在 import 时抛错,load-bearing 不变量、编译期 enforce。bot-daemon app 用 kill_timeout: PM2_DAEMON_KILL_TIMEOUT_MS(非字面量),唯一残留 3500 是 dashboard app(无 Riff drain)正确。
  • 所有关停入口(cmdStop / cmdRestart / cmdStopBot / deleteAllBotmuxProcesses / cleanupLegacyPm2 / rollbackPm2StartAttempt)都经 signalAndAwaitFleet(FLEET_DAEMON_EXIT_WAIT_MS=60s + successorSettle 3.5s)真实 OS 退出,每次 pm2 deleterevalidateExactQuiescentRowBeforeMutation fail-closed 复核 live。旧「5s 轮询后无条件 delete」竞态已消除(残留 maxWaitMs:5_000 是 file-lock 获取超时,非进程退出预算)。--include-pm2assertIncludePm2RestartAdmission:有 live God 一律 throw、从不 signal/kill live God(我读源码确认)。

✅ PID/starttime 代际围栏 + fail-closed(area 2)

  • 身份绑进程出生非裸 PID:process-start-identity.ts Linux 读 /proc/<pid>/stat field 22 starttime(comm 含括号用 lastIndexOf(')') 处理)。PID 复用 / 后继代际在 client(supervisor-shutdown-client.ts:140 发送前重读)+ server(isExactSupervisorShutdownRequest 三元组 larkAppId+bootInstanceId+processStartIdentity → 409)+ 能力扫描(pm2-shutdown-capability.ts:191 throw)三处 fence。
  • 六条 fail-closed 分支全确认(daemon 已退 / 重启后继 / 不可达超时 / 权限不足读不到 /proc / 缺失或陈旧 descriptor / starttime 读不出)——每条都 refuse/throw/retain-fence,不在歧义上前进。
  • assertDaemonPm2GracefulExitPolicy 要求 autorestart=true + stop_exit_codes=[90] 否则 throw+操作员指令;isFleetEntryProvenTerminalAfterSignal 拒绝把 SIGKILL/OOM 归零的 exit_code=0 当优雅(只认 sentinel 90)。

✅ 认证 / 路由可达性 / public surface / 跨平台(area 3)

  • supervisor 路由 handler(dashboard-ipc-server.ts:299)首句 isTrustedHostIpcRequest→403,再 503/400/409(generation mismatch)/202;且服务器级网关(4819)对非 public、非 capability 路由先做 HMAC 校验,secret 不可用或校验失败直接 401、handler 都进不去(我亲自核实)。路由不在 routeHasPublicAccess/routeIsCoreOnlyPublic/routeHasNarrowUntrustedAuth/PUBLIC_READ_PATHS(仅 /__health,/healthz,/api/trigger 等是 public)。
  • 认证 = host-only secret(~/.botmux/.dashboard-secret)HMAC + 三元组代际绑定;沙箱 CLI 读不到 secret(fs-policy deny-by-default,botmux-home 内部白名单故意不含 .dashboard-secret,no-transport bot 整个 authority root 被 deny)→ 无签名 → 401。loopback 仅连通性非身份。nonce TTL 60s + ts 窗口防重放。
  • 跨平台:Linux /proc 为生产路径(daemon 跑 Linux),macOS/win 走 ps/powershell 2s 超时且错误 fail-closed 返 undefined;daemon 启动时绑不到身份直接 throw(daemon.ts:20091)。

✅ 与 #598/#602/#786 真实集成(area 4)

✅ 测试行为化 + 对抗(area 5)—— 1 个 minor

  • 161 tests 全绿(我本地跑)。fleet-shutdown(36) / supervisor-shutdown-client(4) / -ipc(2) / restart-intent-store(15) / restart-report(17) / pm2-*(67) 都是行为断言;loopback integration(1) 是真 HTTP 往返(401 未签 / 503 未注册 / 409 错代际 / 202 接受)。
  • 对抗旁路全闭:未认证→401 网关;重放→nonce+ts 窗口;后继夺端口→bootInstanceId 不同→409;PID 复用→starttime 不同→双侧拒。孤儿回归:fleet/supervisor 路径走同一个 daemon.shutdown() 闭包(含 shuttingDown 幂等 + mutation lease),继承同一 worker-drain→SIGKILL 序列,无新旁路。
  • 🟡 minor(可选)shutdown-supervisor-contract.test.ts(19 条)多为源码文本/顺序断言indexOf grep 源码,如断言路由字符串含 isTrustedHostIpcRequest)——作为正确性证明价值低;但其描述的行为已被 loopback 集成测试独立覆盖,属测试质量提示、非覆盖缺口。建议升级为行为断言或注明是「接线顺序守卫」以免读作虚假覆盖。

验证

build ✅ / tsc --noEmit ✅(merged tree type-green,重要:vitest 走 esbuild 跳类型);#599 全套 161 tests 绿(对已提交 head 跑);我亲自复核了预算 throw / --include-pm2 always-throw / 服务器网关 401 / merge 零删除+路由+1 四项 load-bearing 事实。

净结论:#599 无阻塞项,1 个 minor 测试质量点。作为 #598 的 live 前置,代码质量高、集成无回归、孤儿回归结构性关闭。未经孙晓雪/申晗确认不合码、不部署、不重启 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.

第二轮独立复审结论:核心关停安全链路未发现 blocker,但发现 1 个可稳定复现的 P2,建议修复后再合。

[P2] prepared 报告等待窗口短于合法 fleet 启动事务,导致报告丢失或归因到下一次崩溃

  • src/core/restart-report.ts:106-119 对 prepared intent 只等待一次,默认 45s;超时后直接返回。
  • src/daemon.ts:20960-20972 只在 primary daemon 启动 5s 后调用一次,没有后续重试。
  • 但本 PR 自己允许启动命令最多 30s,验证至少 60s,并按进程数放大到 processCount * 2s。31 bot + dashboard 时验证预算就是 64s;因此合法事务完全可能在 reporter 的最终一次 poll 之后才 commit。

行为探针已复现:

first waiter expiry -> {sent:0,file:true}
commit               -> {sent:0,file:true}
next daemon startup  -> {sent:1,file:false}

即:本次成功的主动重启没有报告;若 10 分钟有效期内随后发生一次非主动崩溃/重启,旧 breadcrumb 会在那次启动被领取,报告被错误归因。若没有后续启动,则报告静默丢失。现有测试只覆盖「commit 发生在 45s 等待内」,没有覆盖超过最后一次 poll 的合法 commit。

建议让 reporter 对 durable prepared 状态持续等到 commit / abort / stale,或由 commit 提供可靠的通知/重试;只把 45s 调到 60s 仍小于可扩展的事务总预算。请补一个行为测试:commit 晚于旧 45s horizon,仍必须在同一次启动中且仅一次发出,之后的 crash startup 不得消费旧报告。

核心安全复核

没有发现 Riff 三阶段关停、PM2 预算、精确 PM2 ID 补偿、PID/starttime 代际围栏、supervisor 双层认证上的 blocker。额外跑了 32 进程 fleet 对抗探针:

  • 32/32 正常退出:单批并发完成,无补偿,live=0。
  • 1 个 generation 拒绝、31 个退出:等待预算后仅恢复 31 个 offline PM2 entry,拒绝者保持原 generation,不误杀、不重启 live 进程。

验证与基线

  • PR head:101ac2110
  • 审核期间 master 已前进到 815d9fe#780),所以当前 head 不再包含最新 master;不过 GitHub 显示可干净合并。我在该最新 master + PR head 的真实 merge tree 上验证:
    • pnpm build ✅
    • 相关 19 文件 / 207 tests ✅
    • 完整 pnpm test ✅(exit 0)
  • 未部署、未重启 live。

Claude 首审提出的源码文本 contract 测试属于测试质量债;已有 loopback 行为测试覆盖核心路径,我不把它单独列为覆盖 blocker。

净结论:先修上述 P2,并基于合入 #780 后的新 head 重跑 restart-report 与 merge-tree 验证;修前不建议合码。

@deepcoldy

Copy link
Copy Markdown
Owner

认可二审 P2(restart-report 45s poll vs 大 fleet start >45s)—— 我独立核实两半都成立

二审提出的 P2 我没有直接采信,独立追了两条链路,确认成立、定级 P2 恰当(非 blocker):

报告侧(restart-report.ts)

  • daemon 对 sendRestartReportIfPending 只调一次(daemon.ts:20962,boot +5s 的单个 setTimeout)。
  • 内部对 intent 轮询上限 preparedCommitWaitMs ?? 45_000(restart-report.ts:111);循环条件 claim.state === 'prepared',超时后 if (claim.state !== 'claimed') return(121)→ 不发报告、breadcrumb 留盘

intent 时序侧(restart-intent-store.ts + pm2-start-transaction.ts)

  • intent 生命周期 prepared → committed(=claimed) | aborted(restart-intent-store.ts:32);claimRestartIntentForReportattemptState==='prepared' 时返回 {state:'prepared'}(227)——只有 commit 后变 claimed 才会发
  • commit(prepared→committed)发生在 fleet start 的 verify 完成之后verifyTimeoutMs)。大 fleet(如 32 进程:~30s start + ≥60s verify ≈ 64s)的 verify+settle 可超过报告侧的 45s poll 窗口 → 报告全程只看到 prepared → 返回不发 → breadcrumb 残留 → 下次启动(很可能是 crash startup)被消费并错误归因

危害范围:仅影响 restart-report DM 的送达/归因(一次该发的重启报告漏发 + 旧 breadcrumb 在下次启动误报为「本次是有意重启」),不涉及孤儿 worker / 血缘 / 数据丢失——所以 P2 而非 blocker,与二审定级一致。

修法方向(供作者/拍板参考):把 preparedCommitWaitMs 默认值抬到覆盖最坏 fleet verify 预算(≥ FLEET_DAEMON_EXIT_WAIT_MS 同量级,或从 fleet 预算派生而非写死 45s),或让 report 侧的等待与 fleet start 事务的 commit 信号联动(commit 落盘即触发一次 report claim)而非固定轮询窗口。

这条不改我首审对核心关停/PID/认证链路「无 blocker」的结论——它在报告层,是独立的 P2。合并顺序/是否本 PR 内修由孙晓雪/申晗拍板;未经确认不合码、不部署。

@deepcoldy

Copy link
Copy Markdown
Owner

P2 修复完成(e375d4fc1)

按双审发现的 restart-report 时序问题补丁如下:

影响面:只改 primary daemon 的重启摘要后台任务与对应单测;不触碰 Riff 关停、worker、PM2 fleet、supervisor 认证,也不区分 CLI/后端/平台。

验证:

  • pnpm build ✅
  • restart-report / restart-intent / supervisor contract / api-only wiring / loopback:5 files,97 tests ✅
  • 首轮全量:864 files / 14107 tests 通过;3 个无关超时
  • 3 个失败文件聚焦复跑:74/74 tests ✅
  • 第二轮全量:865 files / 14108 tests 通过;仅 group-join-shared-routing 与 doc-comment-daemon-concurrency 两个 10s import hook timeout
  • 最新 master 同机全量也复现上述两个同名 hook timeout,归因为全量并发资源抖动,不是本补丁回归
  • 未部署、未重启 live

下一步按群内要求重新双审:先 Claude 独立首审本 delta,再由 Codex 二审。

@deepcoldy

Copy link
Copy Markdown
Owner

复审 #599 P2 修复 @ e375d4fc1(真实 head/祖先/diff/行为/对抗已核,不采信声明)

结论:P2(restart-report 45s 窗口失配)已正确且完整修复;本 PR 未引入任何新缺陷。 五条对抗关注点全部 PASS(concern 4 是既有 by-design P3、非本 PR 引入)。

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

✅ concern 1 — 去掉固定 45s ceiling 后不挂 event loop、不拖慢 shutdown

新循环 while (claim.state === 'prepared') 在 production(preparedCommitWaitMs===undefined)无固定上限,但实际有界

  • production ceiling = RESTART_INTENT_FRESH_MS=10min。每轮 poll 用真实 Date.now(),intent 老过 10min 后 claimRestartIntentForReportTo 命中 !isFresh→返回 absent(restart-intent-store.ts:221)→循环退出。10min >> 最坏 fleet commit ~64s,P2 真修好且不死循环。
  • 不拖慢 shutdown:report 是 boot 时 setTimeout(...).unref()void sendRestartReportIfPending(...) fire-and-forget,shutdown() 全程不引用/不 await 它(grep 确认);每轮 await wait(500ms) 让出事件循环,非 busy-spin。

✅ concern 2 — 多 primary/并发下 at-most-once + 旧 breadcrumb 不被误领

claimRestartIntentForReportTo(restart-intent-store.ts:218-234)整个 read→decide→rmSync→return 都在 withFileLockSync(原子 open(wx) O_EXCL)内:

  • 两并发 daemon 在锁上串行,第一个读到 committed→删→返 claimed,第二个读到 absent→返 absent至多发一次
  • 删除失败 fail-closedrmSync 抛错→return {state:'absent'}(:231),绝不返 claimed→不会没删就发。
  • 旧 breadcrumb 不误领isFresh(10min)是兜底,任何 >10min 的 breadcrumb→absent+删(:221)无视 state;aborted→absent(:228);prepared 永不当「有意重启」发送。crash mid-wait 留 prepared(commit 没跑)→下次 boot 等到老化 stale→删、不报——未验证的重启不产生报告,正确。

✅ concern 3 — 兼容 + 测试真覆盖「45s 后才 commit」

  • 兼容:唯一传 preparedCommitWaitMs 的是测试;production wiring(daemon.ts:20964)省略→undefined→无 ceiling。
  • 两条行为测试(非 shape):① keeps following...past legacy 45s...until commit(mock clock,commit 只在 elapsedMs>45_000 落,断言 sent=1)——我核实旧码此处 unsent(remaining 在 45_000 归零退出),red-on-old discriminating;② stops following...when freshness expires(永不 commit,clock 跳过 10min,断言 wait 只调一次+sent=0+breadcrumb 删)——直接证 concern 1 的循环终止。

🟡 concern 4 — consume-before-send 通知丢失:既有 by-design、非本 PR 引入

claim(rmSync 删 breadcrumb,:230)→ await sendCard(restart-report.ts:150),sendCard 抛错→catch 只 log(:152)→ breadcrumb 已删、通知永久丢一次无重试。但:

  • pre-existinggit show 52f3f6921:restart-report.ts 同结构,P2 修只改等待循环、没碰 claim/send 顺序、没加宽 delete→send 窗口prepared 分支 :227 不做任何 rmSync,删除只在终态 claimed :229-233 发生)。
  • 是刻意的 at-most-once 契约(模块文档 + 测试断言「no retry storm」);改 at-least-once 会重开 concern-2 防的并发双 DM 隐患。失败有 log。定级 pre-existing P3,非 blocker、非本 PR 回归

✅ concern 5 — #789/#792/#780 集成无回归

  • 目标 4 文件(restart-report/restart-intent-store/fleet-shutdown/shutdown-supervisor-contract)89 tests 绿;merge 带入的 PR 测试文件(data-dir-isolation/bridge-fallback/session-card-model/card-builder/codex-transcript/bridge-final-output-retry)374 tests 绿
  • fix(test): 隔离单元测试持久化目录 #792 隔离对写 breadcrumb 的测试完好:unit-setup.ts 在 setupFiles+beforeEach pin SESSION_DATA_DIR 到 per-file mkdtemp;restart-report.test.ts 另用 vi.stubEnv 各自 mkdtemp(afterEach 自动还原);restart-intent-store.test.ts 用显式 *To(dir) 变体不读 env。无跨测试泄漏(unit-data-dir-isolation.test.ts 显式断言泄漏会被修复)。

验证

build ✅ / tsc --noEmit ✅(merged tree type-green);restart-report/fleet 相关 8 文件 123 tests 绿(我本地跑);我亲核了 concern 1(10min ceiling + fire-and-forget 不阻 shutdown)、concern 2(锁内原子 claim+delete + rmSync 失败 fail-closed)、concern 3(两测试 red-on-old)、concern 4(prepared 分支不删、delete→send 窗口未加宽)四项 load-bearing。

净结论:P2 修复正确完整,无新缺陷。#599 = 核心链路无 blocker(上轮)+ P2 已修(本轮)+ 1 个既有 P3(consume-before-send,非本 PR、by-design at-most-once)+ 1 个 minor(contract 测试文本断言,上轮)。作为 #598 的 live 前置就绪。合码顺序/是否顺带处理 P3 由孙晓雪/申晗拍板;未经确认不合码、不部署、不重启 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 第二轮复审 @ e375d4f:P2 修复通过,未发现新 blocker / major;可以进入用户合码确认。

独立核验

  • 真实 head e375d4f,包含最新 master e5a9e99,GitHub MERGEABLE。
  • 生产路径不再用固定 45s 猜测 fleet 事务结束;prepared 会持续轮询,直到 durable state 变成 committed / aborted / stale。
  • production 使用真实 Date.now;restart intent 10 分钟 freshness 是实际终止上限。当前 32 进程 fleet 的 start+verify 最大预算约 94s,余量充足。
  • report 任务由 primary daemon fire-and-forget 启动;shutdown 不 await 它,最终 process.exit(90),500ms await 轮询不会 busy-spin,也不会延长关停预算。
  • 显式 preparedCommitWaitMs 仍保留 caller/test ceiling;旧 45s 后 commit 的新测试对旧实现会失败,属于有效行为回归测试。

额外对抗探针

我没有只复跑 mock clock:

  1. 用独立 Node 子进程在 reporter 已观察到 prepared 后提交 intent;父 daemon 下一轮轮询成功发送一次并清除凭据。
  2. 对 committed intent 启动 8 个跨进程并发 claimant:恰好 1 个得到 claimed,另外 7 个得到 absent,证明锁内 claim+delete 没扩大重复发送面。

探针结果:

crossProcessCommit = sent-once
concurrentClaims = 1 claimed + 7 absent

验证

  • pnpm build ✅
  • 相关 5 文件 / 97 tests ✅
  • Claude 复审扩展集:8 文件 / 123 tests ✅
  • 全量套件两次只有高并发 import hook 超时;同名超时在最新 master 同机可复现,涉及失败文件聚焦复跑 74/74 ✅,非本补丁回归。
  • 未部署、未重启 live。

非阻塞遗留

claim 后先删除 breadcrumb、再调用 Lark;若发送本身失败,本次通知仍会丢。这是旧实现既有的 at-most-once 语义,不是本补丁新增,也不影响本次确认的 45s 漏报修复。若要提升为可靠投递,建议单独做 durable outbox + 飞书稳定 uuid 去重,不能简单把删除挪到发送后,否则崩溃窗口会产生重复 DM。

净结论:双审通过。#599 的核心关停链路继续保持无 blocker;restart-report P2 已闭环。等待孙晓雪/申晗明确确认后再合码,不部署、不重启 live。

@deepcoldy
deepcoldy merged commit 2d74199 into deepcoldy:master Aug 9, 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