Skip to content

refactor(core): 增加机器人级 turn admission/mutation gate - #596

Merged
deepcoldy merged 2 commits into
deepcoldy:masterfrom
xiaoxueSunn:split/bot-turn-mutation-gate
Jul 26, 2026
Merged

refactor(core): 增加机器人级 turn admission/mutation gate#596
deepcoldy merged 2 commits into
deepcoldy:masterfrom
xiaoxueSunn:split/bot-turn-mutation-gate

Conversation

@xiaoxueSunn

Copy link
Copy Markdown
Contributor

背景

Codex App 生命周期修复与 RIFF 安全关停都需要同一个机器人维度的并发边界:普通 turn 可以并发进入,替换 worker generation 或关停等 mutation 必须先阻止新 turn、等待已进入 turn 排空,再独占执行。现有 master 没有可复用的原语。

改动

  • 新增每机器人 admission/mutation gate。
  • 支持 admission 内升级为 mutation,避免显式关闭路径自锁。
  • 支持带绝对截止时间的可取消 mutation 获取;超时 waiter 会从队列移除,动作不会在调用方返回后迟到执行。
  • 支持 detached admission/mutation,防止 AsyncLocalStorage 子任务冒用已结束 lease。
  • 本 PR 只增加基础模块与测试,不接入运行时路径;后续 Codex App 核心与 RIFF PR 共用该模块。

影响范围

纯新增基础模块;当前运行时行为不变。按 larkAppId 隔离,不影响其它机器人。

验证

  • git diff --check:通过
  • pnpm build:通过
  • pnpm exec vitest run test/bot-turn-mutation-gate.test.ts:1 个文件、16 项测试全部通过

未验证

本 PR 尚未接入 daemon/worker,因此无需 live daemon 或 PM2 验证;运行时接入将在后续独立 PR 中验证。

@xiaoxueSunn
xiaoxueSunn requested a review from deepcoldy as a code owner July 26, 2026 04:26

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

首次 Review(Claude)— 结论:✅ 可合入(等 codex 复审 + 申晗确认后再合码)

按 CLAUDE.md 影响范围要求做了跨面评估,并跑了对抗式验证。

这个 PR 在做什么(白话)

它给每个机器人(按 larkAppId 隔离)加了一把「准入/排空/独占变更」闸门原语。背景:Codex App 生命周期修复、RIFF 安全关停都需要「普通消息 turn 可以并发进,但替换 worker generation / 关停这类 mutation 必须先挡住新 turn、等已进入的 turn 排空、再独占执行」,master 里没有可复用的原语,所以先把这块基础模块单独拆出来(本 PR 只加模块 + 测试,不接入任何运行时路径)。

四个对外 API:

  • withBotTurnAdmission — 一次普通 turn,可并发;有 mutation 持闸时阻塞;同 bot 嵌套调用是可重入的(靠 AsyncLocalStorage 认领的 lease)。
  • withBotTurnMutation — 独占:关闸(mutating=true)→ 等所有在飞 admission 排空 → 执行 → 重新开闸。难点是 upgrade:mutation 若发现自己是在一个「已准入的 handler」里被调用(典型:消息/卡片 handler 里触发关停),就把外层这条 admission「释放 → 排空其它 turn → 独占变更 → 在开闸前原子地重新认领同一条 lease」,这样调用方在状态变更后还能安全地继续用原 admission 收尾(投递/日志)。
  • tryWithBotTurnMutation — 带绝对截止时间的有界版本,保证 返回 {acquired:false} 之后 action 绝不会迟到执行(移除超时 waiter + 回滚已关的闸)。
  • runDetached* — fire-and-forget 用;清空两个 ALS context,让浮起来的子任务去认领全新计数的 lease,而不是冒用祖先那条「已被吊销但仍挂在 ALS 里」的 lease。

为什么设计成这样(几个关键不变量)

  1. 两个 mutation 不可能同时进场 —— while(state.mutating) 检查与 state.mutating=true 之间没有 await,单线程下这条 check→set 是原子的,被 wakeAll 同批唤醒的多个 mutation 里只有第一个微任务能关闸,其余重新回到 openWaiters
  2. upgrade 的「先重认领、后开闸」次序是核心 —— finally 里顺序是 lease.active=false → (upgrading && !ownerFinished 时)activeAdmissions++ / inheritedAdmission.active=truemutating=falsewakeAll。因为重认领在开闸之前,被唤醒的排队 mutation 会看到 activeAdmissions>0,正确地在 drainWaiters 上继续等 upgrade 那条 handler 的尾部工作排空。
  3. ownerFinished 守卫防「幽灵计数」 —— 如果 upgrade 的外层 admission 已经先返回了(fire-and-forget 浮起的 mutation),就不能再把计数加回去,否则 activeAdmissions 永远停在 1、之后所有 mutation 全部饿死。
  4. 进程模型对齐:worker 是 fork() 子进程,这把闸是 daemon 单进程内的 module-level 单例,只守 daemon 侧的 inbound/HTTP turn 准备阶段,按 larkAppId 分桶,不影响其它 bot,也不需要跨进程协调 —— 粒度正确。

我做的验证

  • pnpm install && pnpm build:✅ 通过
  • pnpm exec vitest run test/bot-turn-mutation-gate.test.ts:✅ 16/16 通过
  • 影响面:git diff master...HEAD 仅 2 个新增文件(296 + 400 行),全仓无其它模块 import 该文件,master 上无同类原语 —— 纯新增、运行时行为不变,与 PR 描述一致。
  • 对抗式可执行探针:对 6 个维度(upgrade 竞态 / 有界一致性 / ALS 冒用 / 计数不变量 / 唤醒公平性 / 截止时间边界)各写 4 条会失败于 bug 的 vitest 探针,独立跑真实仓库。5 个维度全部完成 = 20 条探针 20 PASS,0 真 bug
    • 其中 count-invariants 维度做了变异测试:把 !ownerFinished 守卫删掉后,探针出现真实 30s 死锁activeAdmissions 被 stranded 在 1)—— 证明这条守卫在无界和有界两条路径上都是 load-bearing 的,不是死代码。
    • deadline-timer 维度的 verifier 因真实定时器 sleep 过长在 6 次尝试里都 stall(harness 超时假象,非代码缺陷);且我最关心的「截止时间之后被唤醒的 drain waiter 必须拒绝、action 绝不执行」这条已在 bounded-consistency 维度独立覆盖并 PASS,PR 自带测试里也有两条 timer 用例。

非阻塞观察(P3,可留 follow-up)

  • upgrade_conflict 返回值 0 覆盖:现有 16 条测试里没有一条驱动「admission 内调用 tryWithBotTurnMutation 且已有别的 mutation 持闸」这条早退路径(探针证明它行为正确、admission 保持不变)。建议后续接入 PR 补一条回归测试锚定契约。
  • 模块目前无任何 runtime importer,属「基础先行、后续接入」拆分模式 —— 合理,但需确保后续 Codex App / RIFF 接入 PR 真正落地,避免长期空挂 bitrot。接入时才是真正验证 drain 语义对 live turn 流的地方(PtyBackend / TmuxBackend、话题会话 / 群会话 / adopt-restore 各组合)。

结论:代码质量高、注释准确描述了每个 hazard、对抗验证零真 bug,可合入。已 @ codex 复审。未获申晗确认前不合码。

@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 复审发现阻塞性自锁,我确认属实

Codex 在复审中发现一个阻塞性并发自锁,我已在 head dcf37d4 独立复现,撤回此前的「可合入」结论

独立复现(我跑的探针)

在一个 admission 内用 Promise.all 并发起两个 withBotTurnMutation(均被父 action 结构化 await,非 fire-and-forget):

  • 无界:DEADLOCK-timeout, events=["m1"] —— 永久挂死,只有 m1 执行
  • 该 bot 永久锁死:之后任何 admission 都 blocked
  • 有界对照:r2={acquired:false,reason:'timeout'},m2 从不执行
  • 顺序调用对照组正常:m1,m2,outer-done

根因(与 Codex 一致,bot-turn-mutation-gate.ts:143-154,168-173

两个 mutation 共享同一条 admission lease

  1. m1 把共享 lease 的 active 改为 false 并关闸。
  2. 同步发起的 m2 看到 active=false,把 upgrading 永久快照为 false,随后等第一把闸。
  3. m1 完成时先把外层 admission 重认领为 activeAdmissions=1,再唤醒 m2。
  4. m2 醒来关闸并等 activeAdmissions===0,但这个计数只能由正在 await Promise.all 的外层释放 → 环形等待,mutating=true 卡死,该 bot 后续全挡。

为什么我的首审漏了

我的 upgrade-races 对抗探针测的是「两个各自独立 admission 的 upgrader」(各持自己的 lease,能正确串行化),而这个 bug 需要两个 mutation 共享同一条 lease——upgrading 只在入口快照 active 是不够的。这是我探针设计的真实盲区,教训记下。

下一步

正在按 Codex 建议实现修复:让同一 admission lease 识别/串行化 in-flight upgrade(用 lease 上的 in-flight 计数,而非只在入口快照 active),并补回归测试(同 lease 并发无界 mutation、无界+bounded 混合)。修复验证通过后再单独更新 + @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

修复计划(按 Codex 验收标准实现中)

根因已确认:upgrading 只在入口对 inheritedAdmission.active一次性快照。同一 admission lease 被多个并发 mutation 共享时,先行 mutation 临时释放该 lease(active=false)会让后到的 mutation 永久误判为 top-level,进而等待一个只有仍挂起的外层 owner 才能释放的 drain 计数 → 环形等待。

修复思路:在 lease 上串行化 in-flight upgrade

AdmissionLease 增加 upgradeInFlight + upgradeWaiters,并把「是否 upgrade」从「入口快照 active」改为「按 lease 归属 + 串行排队」:

  1. mutation 进入时,若继承到 admission lease 且 owner 未结束,先等待该 lease 上的 in-flight upgrade 完成(同一 lease 只允许一个 upgrade 在飞,后续排队),再决定自己是否 upgrade。
  2. upgrade 结束(正常/回滚/抛错)在 finally 里统一 endUpgrade:重认领 admission 槽(owner 未结束时)→ 清 upgradeInFlight → 唤醒下一个排队 upgrader。重认领仍在开闸前,保持既有「reacquire-before-reopen」不变量。
  3. bounded 路径同样串行化,但每一步等待都受真实 deadline 约束——不因等自己的外层 lease 而必然超时,超时则回滚且 action 绝不迟到执行。
  4. 保留 upgrade_conflict:仅当「本 lease 仍 active(未被同 lease 兄弟释放)」且有独立 mutation 占闸时返回——此时 active===true,可与同 lease 兄弟场景(active===false)明确区分。

按 Codex 验收标准补的回归

  • 同 lease 两个无界 mutation:exactly-once、严格不重叠、外层正常结束、之后仍可进入
  • 无界 + bounded 混合:bounded 语义由真实 deadline 决定
  • 第一个/第二个 action 抛错:队列继续推进、activeAdmissions/mutating 完整恢复
  • outer 提前 ownerFinished / detached 分支 / 跨 bot 隔离:无幽灵计数、不跨 bot 串行化

实现 + 自测(含 Codex 死锁探针翻绿)通过后带新 commit SHA 更新。

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

重新 Review:作者修复提交 78a5e1d4 —— ✅ 死锁已修复,实测通过

针对 Codex 复审发现的并发自锁,作者推了 78a5e1d4c fix(core): serialize same-admission mutation upgrades。我基于 PR 当前 head 重新完整 review + 独立对抗验证,结论:修复正确、测试充分。

修复机制(读懂了)

AdmissionLease 新增 pendingUpgrades / upgradeLocked / upgradeWaiters,把「是否 upgrade」从「入口一次性快照 active」改为按 lease 串行排队

  • shouldQueueAdmissionUpgrade 的关键:active || pendingUpgrades > 0 才排队 —— 兄弟 mutation 即使看到被先行者临时释放的 active=false,只要 pendingUpgrades>0 仍会正确进队列,不再误判成独立 top-level mutation 去等自己那个挂起的外层 admission。
  • acquireAdmissionUpgrade / releaseAdmissionUpgrade:直接把 lock 所有权移交下一个排队者(不清 upgradeLocked),保证同 lease 的 action 串行不重叠。
  • bounded 版 acquireAdmissionUpgradeBefore:deadline-aware 排队,超时精确摘除自身 waiter;handoff 与 timer 竞争时按 Date.now() < deadlineMsacquired / expired_owner,过期只释放槽、action 绝不迟到执行。

我的实测验证

  • pnpm build ✅;PR 测试 23/23 ✅(作者新增 5 条同-lease 用例)
  • Codex 的原始死锁探针outcome=completed, events=["m1","m2","outer-done"] —— 不再挂死 ✅
  • 作者回归测试是 discriminating 的:把源码回退到旧 head、只跑作者的 serializes concurrent mutations that share one admission lease30s 超时死锁;换回修复源码则通过 —— 证明测试真能抓 bug、修复真能解 ✅
  • 我另写 9 条独立对抗探针全 PASS,覆盖 Codex 六条验收标准:
    • A:独立 top-level mutation 与在飞同-lease upgrade 竞争 —— 我最担心会引入新死锁的场景,实测 ["m1","indep","m2","outer-done"] 正常交错,无死锁
    • B/末:3~5 个同-lease 并发 mutation 全 exactly-once、maxConcurrent===1 严格不重叠
    • C:bounded 兄弟排在挂死的 unbounded 兄弟后,按真实 30ms deadline 超时 {acquired:false,reason:'timeout'}b-ran 从不执行(不因等自己外层 lease 而必然超时,也不迟到)
    • D/E:第一个 / 第二个 action 抛错 → 队列继续推进、activeAdmissions/mutating 完整恢复
    • F:fire-and-forget 兄弟 upgrade、owner 先返回 → 仍执行、无幽灵计数
    • G:跨 bot 隔离,两 bot 各自并发 mutation 不互相串行化
    • H/I:混合 bounded+unbounded 及后续 admission/mutation 均可正常进入
  • 作者两条最微妙的新用例(rejects an overdue same-lease queue handoff before its timer callback 用 fake timer 造 handoff-vs-timer 竞争、does not restore a ghost admission when a queued upgrade outlives its owner)设计严谨,正好覆盖 deadline + fire-and-forget 边界。

范围与回归

  • 仍只 2 个文件、无任何 runtime importer、build 全绿、本地树与 PR head 78a5e1d4 完全一致(diff 为空)。纯新增基础模块,运行时行为不变。

结论:修复正确、验收标准逐条满足,可合入。 已 @ Codex 复审确认。⚠️ 未获申晗最终确认前不合码。

@deepcoldy
deepcoldy merged commit 6dbaefb into deepcoldy:master Jul 26, 2026
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