Skip to content

fix(send): 按轮绑定 mention-back 发送者 - #750

Merged
deepcoldy merged 11 commits into
masterfrom
codex/fix-turn-bound-mention-back
Aug 7, 2026
Merged

fix(send): 按轮绑定 mention-back 发送者#750
deepcoldy merged 11 commits into
masterfrom
codex/fix-turn-bound-mention-back

Conversation

@xiongz-c

@xiongz-c xiongz-c commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

背景

在多 Bot 群中,Bot B 收到 Bot A 的消息后显式执行 botmux send --mention-back,预期应 @ 回本轮发送者 Bot A。现状存在两层退化:

  1. daemon 把 quoteTargetSenderOpenId 存成 session 级单槽;排队或并发轮次到达时,后一轮会覆盖前一轮 sender。
  2. botmux send 虽然已经按 BOTMUX_TURN_ID 读取 replyTargets[turnId] 的回复落点,却仍从上述最新单槽读取 sender,导致“落点来自 A 轮、sender 来自 B 轮”。
  3. fix(send): 多人会话禁用 --mention-back 并修复人数查询注册顺序(兜底卡断链 deferred) #689 增加的参与人数门禁会在人数大于 2 时拒绝 --mention-back,迫使模型从上下文重新选择 --mention。这使原本可由平台事件确定的 sender 退化为模型猜测,表现为偶发 @ 到会话 owner 或其它参与者。

修复

  • 扩展现有 replyTargets[turnId],把 rootMessageIdsenderOpenIdquoteOnlysubstitute 原子写入同一条 per-turn 记录。
  • thread scope 和 chat rootless turn 也写入记录;它们即使不需要 per-turn root,仍需要本轮 sender。
  • botmux send --mention-back 只读取当前 turnId 的 sender;VC 显式 per-turn origin 仍保持最高优先级。
  • 兼容旧 session:仅当 quoteTargetId === currentTurnId 时,才允许从旧 sender 单槽回退;reply slot 同样要求 slot.turnId === currentTurnId
  • 32 条上限淘汰或 turn 不匹配时 fail-closed,由现有 mention decision 校验拒绝 --mention-back,不会借用后一轮 sender。
  • 删除 --mention-back 的群人数查询与人数门禁。

为什么挑战 #689 的人数门禁

#689 的担忧“多人群里触发者不一定是业务上的收件人”是成立的,但它适用于“系统或模型尚未选择收件人”的场景,不适用于已经显式选择 --mention-back 的调用。

--mention-back 的命令语义就是“@ 本轮真实发送者”。调用方如果要回复会话 owner、其他人或第三个 Bot,应使用显式 --mention;人数门禁无法判断这些业务意图,也没有为路由增加新信息。相反,门禁拒绝确定性路径后让模型从 <mentions> 等上下文重建同一个 open_id,扩大了误路由面。

因此本修复不按 human/bot 做不对称放行,也不依赖 quoteTargetSenderIsBot。后者包含平台 sender_type 与 cross-ref 学习两类信号,不应成为发送权限边界;这里所需的只是平台入站事件中、与当前 turn 原子绑定的真实 sender open_id。无 per-turn sender 时统一 fail-closed。

本 PR 不改变 daemon 忘记调用 botmux send 时的 owner 兜底,也不限制显式 --mention,因此 #689 对“无明确路由决定”的保守处理仍保留在原路径。

影响面

  • 公共层:session 持久化的 replyTargets entry 新增可选 sender,旧数据向后兼容。
  • IM / 会话:覆盖 thread、chat、rootless chat、passthrough、现有 session follow-up、自动创建;定时/系统轮次不伪造 sender。
  • CLI / 后端:所有 CLI 与 PTY/Tmux 等后端共享同一 send 解析逻辑;未修改各 CLI adapter 和后端实现。
  • 网络:--mention-back 不再额外调用群成员统计接口,也不再因统计软失败 {999,999} 被误拦。
  • 未修改显式 --mention--no-mention、top-level、daemon fallback card 与 bot trust 判定。

验证

通过:

  • pnpm vitest run test/reply-target-fallback.test.ts test/send-policy.test.ts test/cli-send-hook-context.test.ts test/daemon-turn-reply-sender-wiring.test.ts — 4 files / 62 tests passed
  • pnpm build — TypeScript、dashboard bundle、domain audit、dist audit 全部通过
  • git diff --check — 通过
  • pnpm test — 800 files / 12842 tests passed;本 PR 新增与相关测试均通过

全量测试有 2 个与本改动无关的既有 macOS 文件系统失败,单独复跑结果一致:

  • test/v2-run-archive.test.ts:对目录调用 rmSync(path, { force: true }),报 Path is a directory
  • test/v3-host-execution.test.ts:同类目录清理错误

未部署 live daemon;本 PR 不抢占本机全局 botmux checkout,合并前可按需做飞书多 Bot 手工验证。

@xiongz-c
xiongz-c requested a review from deepcoldy as a code owner August 5, 2026 14:00

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

复核 head b3534a779:当前有 1 个 blocking;其余实现未发现新的代码阻塞。

🔴 thread-scope 替身轮会错误关闭 streaming card

调用链成立:

  1. 话题群替身触发在 event-dispatcher.ts 中保留 thread scope 与原话题锚点。
  2. 本 PR 将 beginReplyTargetTurn()replyTargets[turnId] 写入移到 scope 早返之前,因此 thread-scope 轮也会持久化 { substitute: true, senderOpenId }
  3. isSubstituteTurn(ds, turnId) 优先读取该 map entry,于是同一 thread-scope 轮由原先的 false 变为 true
  4. worker 的 readyscreen_update 都携带该入站 turnId,并分别经过 streamingCardDisabled(ds, msg.turnId);初始卡会被跳过,后续卡创建/PATCH 也会被跳过。
  5. substitute control card 明确只补偿 scope === 'chat'noteTurnReceived 虽通常会用反应补反馈,但配置 silentTurnReactions 后同样被关闭。因此该组合可以整轮没有状态反馈。

最小探针在当前 head 得到:

{"scope":"thread","replyTargets":{"om_turn":{"substitute":true,"senderOpenId":"ou_sender"}},"isSubstituteTurn":true,"isSubstituteWithoutTurn":false}

现有相关测试 53/53 通过,但没有覆盖 thread scope + substitute=true + exact turnId 这一组合。

建议把 scope 约束集中到卡片抑制判定:最小改法是让 isSubstituteTurn 接受 scope,顶部对非 chat 返回 false,并把注释改成“是否为会关闭 streaming card 的替身轮”;补一条 thread-scope substitute 返回 false 的回归测试。若希望保留 helper 的字面语义,也可在 streamingCardDisabledstreamingCardDisabledFor 两处写 ds.scope === 'chat' && isSubstituteTurn(...),但需要两处同步维护。

🟡 policy 需明确确认

删除多人会话 --mention-back 门禁的技术论证是自洽的:命令语义确实是回复本轮真实 sender,per-turn sender 也比让模型重建 open_id 更确定。但这是对已合入保守策略的反转,合并前仍需要维护者明确确认;本次没有执行合并。

验证:

  • pnpm build:通过
  • git diff --check origin/master...HEAD:通过
  • pnpm exec vitest run --project unit test/reply-target-fallback.test.ts test/turn-reactions.test.ts test/substitute-control-card.test.ts:3 files / 53 tests passed
  • PR CI / CodeQL:当前均通过

@xiongz-c
xiongz-c force-pushed the codex/fix-turn-bound-mention-back branch from b3534a7 to 08bf530 Compare August 6, 2026 02:48
@deepcoldy

Copy link
Copy Markdown
Owner

补充修正前一条 review 的修法建议:只在 isSubstituteTurn() 对非 chat scope 早返并不完整。

cli.ts 的 footer 寻址还会直接读取 pickTurnReplyTarget(...).substitute,不经过 isSubstituteTurn()。当前 head 的 thread-scope map entry 一旦带上 substitute: truebuildFooterAddressing() 就会从普通 thread 语义的 owner 寻址切到 substitute 分支的 last-caller 寻址。最小探针结果:

{
  "turnReplyTarget":{"substitute":true,"senderOpenId":"ou_sender","turnId":"om_turn"},
  "masterLike":{"sendTo":"ou_owner","cc":[]},
  "polluted":{"sendTo":"ou_sender","cc":[]}
}

因此更准确的主修应放在写入边界:beginReplyTargetTurn() 对 thread scope 仍写 senderOpenId + updatedAt,但不持久化 chat-scope 专属的 quoteOnly/substitute;这样 --mention-back 所需 sender 不丢,卡片和 footer 两个读取点同时恢复原语义。

建议同时保留两处纵深防御,以覆盖历史/异常污染 entry:

  • isSubstituteTurn():非 chat scope 返回 false;
  • footer isSubstitute:显式要求 isChatScope

回归测试应分别锁住卡片判定与 footer 寻址两个读取点,并确认 thread-scope entry 仍保留 sender、但不带 quoteOnly/substitute

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

增量复核结论:thread-scope 写入边界已收敛正确,rootMessageId/quoteOnly/substitute 不再落入 thread per-turn entry;isSubstituteTurn 与 footer 的 scope 守卫也能兼容已有污染记录。人数门禁主链同样是 fail-closed:bot 触发按精确轮次短路,人类/未知触发在多人群中校验人数,统计或动态导入失败均拒绝。

仍有 1 个 sender 归属缺口需要补齐:startInitialPassthroughSession 的 thread caller 已经用 isForeignBot(含 isKnownPeerBot cross-ref 兜底)识别了 bot,并把它传入 botSender;但函数内部在 initialPassthroughSender.typequoteTargetSenderIsBotbeginReplyTargetTurn(...senderIsBot) 及 registration-loser handoff 四处重新只看 parsed.senderType === 'app' || 'bot'。当飞书 sender_type 缺失/变值但 cross-ref 已识别 peer 时,其它 thread 路径均按 bot 处理,唯独冷启动 passthrough 会把本轮持久化成 human,导致多人群中的 --mention-back 再次进入人数门禁并被拒绝。

建议把 quota 使用的 platform-stamped botSender 与回复归属使用的 senderIsBot 分开传递:new-topic caller 传其完整 bot 判定,thread caller 传 isForeignBot,函数内上述四处统一使用 senderIsBot。这样既补齐 bot→bot 放行,也不扩大 quota 信任边界。

验证:pnpm build 通过;相关 13 个测试文件共 193 tests 通过;当前 GitHub build / CodeQL checks 全绿。

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

复核通过。此前两项阻塞均已闭环:

  • thread-scope per-turn entry 只持久化 sender attribution,不再携带 chat-scope 的 rootMessageId/quoteOnly/substitute;卡片与 footer 两个读取点均有 scope 纵深防御。
  • senderIsBot 按轮传递;冷启动 passthrough 现在把 quota 的 botSender 与 reply attribution 的 senderIsBot 分离,并在 primary path、quote legacy slot、pending sender、registration-loser handoff 四处统一使用 caller-resolved 值。thread caller 使用 isBotSenderType || isForeignBot 合理:该字段判定“是否 bot”,不会扩大 quota 信任边界;self echo 与缺失 open_id 分别由 dispatcher / mention-back hard gate 收口。
  • bot 触发直接跳过人数查询;human/unknown 触发在多人群走人数门禁,统计软失败及导入异常均 fail-closed。
  • 6a84833 已同步此前遗漏的 registration-loser source assertion。

独立验证:git diff --check 通过;pnpm build 通过;相关 14 个测试文件共 269 tests 全绿。当前 head 可批准;合并仍应遵循既定的产品策略确认与 CI 门禁。

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

ef92513c3 的主路径和定向测试是绿的,但当前“窗口近似只会偏宽、不会漏禁”的不变量还没有成立,需补以下问题后再合并:

  1. 参与者窗口缺失时会 fail-open。 collectTurnWindowParticipants() 在 turnId/map/anchor/participants 不可用时都返回 [],而 mentionBackAmbiguity([]) 把它当成“明确 0 人”并放行。此时 pickTurnReplyTarget() 仍可能从 per-turn/legacy sender 槽拿到 senderOpenId,所以升级中正在执行的旧轮、旧持久化记录等场景会绕过歧义门禁并自动 @。请区分“窗口完整且只有一人”和“窗口未知/不完整”;后者在 group 中应 fail toward explicit --mention/--no-mention

  2. 非 open_id 形态的 @ 参与者被直接漏掉。 parseEventMessage() 明确让 app_id mention 的 openIdundefinedtest/message-parser.test.ts 已锁定),而 buildTurnParticipants()!m.openId 直接 continue。因此“真人 sender + @Otherbot(app_id 形态)”只留下真人一个 participant,窗口非空且 distinct=1,仍放行 --mention-back;空窗口守卫也兜不住。user_id/union_id 形态同理。请保留并解析这些身份,能换成当前 app 可用 open_id 就作为候选;无法换出时至少把窗口标成 incomplete 并阻止自动 @,不能把它当成未参与。

  3. 32 条裁剪会产生“部分窗口”,不只是空窗口。 当前 worker 没有 32 条 type-ahead 深度上限,且 Codex 可把连续输入 steer 进 active turn。90 秒内 A 先发一条、B 再发 32+ 条时,A 的 entry 可被裁掉,而当前 B anchor/participants 仍存在;collector 返回非空单人 B,仍会误放行。请持久化窗口完整性/裁剪水位,或维护不会因 per-turn entry 淘汰丢参与者的 rolling aggregate。只对空数组 fail-closed 不能覆盖该路径。

另外请一并修正候选元数据与仓库规范:sender participant 当前不带 name;isKnownPeerBot() === false 也只表示“不是已知 peer”,不能证明是人,第三方 bot 会被标错;未知类型应诚实显示为未知。src/core/reply-target.ts 新注释中的真人姓名/内部批准描述不应进入公开源码,请改成中性技术表述。

独立验证:pnpm build 通过;相关 5 个测试文件 206/206 通过;git diff --check 通过。现有测试把 participants: [] 断言为放行,且没有覆盖 app_id mention、部分窗口淘汰和未知类型,因而没有捕获上述 fail-open。

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

c10c80a55 的共享裁剪 helper + prune watermark 方向是对的,但当前 incomplete 闭环仍有以下缺口:

  1. 旧 schema 的 anchor 仍会假 complete。 collectTurnWindowParticipants() 只在 anchor 不存在时置 incomplete;若旧持久化 entry 是 { updatedAt, senderOpenId }、没有 participants/participantsIncomplete,会返回 {participants: [], incomplete: false},随后放行 --mention-back。现有测试测的是“anchor 完全不存在”,不是这个升级场景。窗内 entry 有 sender 但缺 participants 字段时应置 incomplete,并把已知 senderOpenId 合成可执行候选;legacy slot 只有 sender 的路径也要把该 sender 交给错误提示。

  2. app_id 形态的 self mention 会误拦正常单人轮。 helper 只能按 m.openId === selfOpenId 排除当前 bot;parser 对 app_id mention 保留 openId: undefinedLarkMention 未保存 appId,所以普通 @当前bot 帮我 若 self mention 是 app_id 形态,会落进 !m.openId → incomplete=true。请把 mentionIdentity(m).appId 保进 LarkMention,向 helper 传 self app id,先排除 self app_id;其它不可解析 mention 才标 incomplete。仓库现有 dispatcher 测试已经证明 self/other bot 的 app_id mention 都是支持形态。

  3. 缺失 sender identity 也会被当 complete。 buildTurnParticipantsFrom({}, undefined, ...) 当前返回 complete 空集。realtime/message-listener 已有 only-app_id bot sender 路径;若该消息与后续有 open_id 的 type-ahead sibling 同窗,未知 sender 被漏掉,窗口可能剩单人并放行。该 helper 只服务真实入站消息,无法得到 sender open_id 时也应置 incomplete(明确识别为 self 的 sender除外)。

  4. sender 三态在 caller 处仍被压成 boolean。 new-topic/existing/auto-create 传的是 isForeignBot*;sender_type 缺失/异常且 cross-ref 未命中时得到 false,于是错误列表仍标“人”。应把路由/owner 用 boolean 与展示身份分开:平台 app/bot 或 peer→true,明确 sender_type=user→false,其它→undefined;initial passthrough 的 fallback 也不要用一个 false-expression 抹掉 unknown。

另有一个持久化缺口:inheritTriggerReplyAnchor() 的新 watermark 只改内存;existing trigger 路径在调用后没有 sessionStore.updateSession(),新增测试也只断言内存。对当前 @ 门禁通常会因磁盘缺 synthetic anchor 而保守拦截,但“第二 writer 的 watermark 可被独立 botmux send 读取”并未成立,建议让 caller 在 arm 后持久化并补集成断言。

本地纯探针结果:legacy anchor=incomplete:false,self app_id mention=incomplete:true,missing sender=incomplete:false;现有相关 6 文件 229/229 通过,说明测试口径尚未覆盖这些边界。PR 当前还与 master 冲突,补丁闭环后需 rebase 再做最终验证。

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

3b2b41517 已修复上一轮列出的旧 schema、app_id self、缺失 sender、展示三态及 synthetic anchor 落盘问题;build 与相关回归均通过。但 participant 输入仍有两处 fail-open,需要继续收口:

  1. [blocking] post inline at 未进入参与者窗口。 路由门 messageMentionsBot 会扫描 post content 中的 inline at,而 parseEventMessage 只把 message.mentions[] 写入 parsed.mentions。post 常见 mentions: [] 时,消息能正常路由,但 buildTurnParticipants(..., parsed.mentions) 只记录 sender。实测 post 同时含 @self(cli_self)@OtherBot(cli_other) 时,当前结果为单一 human participant 且 incomplete:false,因此 --mention-back 被放行。应建立隔离的 participant-only post-at 提取,并覆盖 forward seed + follow-up 两份原始消息;self open_id/app_id 排除,其它 ou_ 进入候选,cli_/未知形态置 incomplete。不要改变通用 parsed.mentions 语义。

  2. [blocking] 新增的 LarkMention.appId 没有贯穿 mergeMessageMentions 该 helper 既未把 appId 纳入 identity,也未像 openId/userId/unionId 一样显式富化。跨消息 mention key 会从 @_user_1 重新编号;若 forward seed 的同名 bot 是 cli_self、follow-up 的同名 bot 是 cli_other,当前会按 key+name 合并并保留 seed 的 cli_self。participant core 随后把唯一记录当 self 排除,得到单人 complete 窗口。探针已复现。identity 应优先包含 app:${appId},合并字段也应显式保留 appId,并补同名不同 appId 不合并回归。

另请在 participant core 收紧“可执行 open_id”域:仓库已有 structured @all 形态 {open_id:'all'},当前会把 all 列为候选并建议非法的 --mention allall/非 ou_ 值应不进入候选并置 incomplete;该校验应覆盖 text 与 post 两条 lane。

本地验证:

  • pnpm build:通过。
  • 8 个相关测试文件:274/274 通过。
  • 独立探针:post inline-at 与同名不同 appId merge 两条均能稳定复现上述结果。

当前 PR 仍与 master 冲突;同意先收敛行为,再统一 rebase,并在 rebase 后 head 重跑 build、定向与全量回归。

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

7353e1b4b 已正确修复正常路径下的 post inline-at participant lane、mergeMessageMentions 的 appId identity/字段传播,以及 structured/post @all 的不可执行候选问题;但 registration CAS loser 路径仍会丢 forward seed 的 post participants:

  • PreparedThreadReply 没有携带 participant raw messages 或已提取的 routing-only mentions;
  • new-topic CAS loser 调用 handleThreadReply(..., prepared) 时只传 parsed/resources/attachments/sender;
  • winner 重绑本轮时,existingWindow 只从 data.message 提取 post-at。

因此「forward seed post 含 OtherBot + follow-up 仅 self」在正常 new-topic 路径会 incomplete,但只要新 session 注册竞争输给现有 winner,scratch 的完整 window 随 rollback 被关闭,winner 仅按 follow-up 重算,重新得到单 sender complete 窗口,--mention-back 仍 fail-open。

建议在 PreparedThreadReply 中显式携带已提取的 post participant mentions,并让两个 registration-loser handoff 都传递;winner 的 existingWindow 在 prepared 路径消费这份完整 seed+follow-up 集。传已提取 mentions 比传 raw message 更窄,也不依赖 ctx.forwardSeedData 生命周期。需补 handoff 级回归/类型与接线断言,当前 source 测试只锁正常 new-topic call 同时出现 current+seed 两条 raw message,未锁 loser path。

验证:

  • pnpm build:通过。
  • 9 个相关测试文件:286/286 通过。
  • 正常路径纯函数探针:post inline-at、appId merge、@ALL 均已转为预期;剩余问题来自 CAS loser 接线缺失。

另有一个不阻塞本次 merge 的既有路由不一致:纯 post inline cli_selfmessage.mentions=[] 时,participant extractor 能识别 self,但 messageMentionsBot inline 分支只比较 botOpenId、不比较 larkAppId,因此消息本身不会路由;mentionsAnotherMember 也会把它误判为别人。建议独立收口,避免本 PR 继续扩面。

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

6d5919723 已修复第一处(new-topic)registration CAS loser 的 prepared participant handoff,但声明中的“两个 loser handoff 都携带”尚未完全落地:auto-create CAS loser 构造 PreparedThreadReply 时仍没有传 postParticipantMentions: autoCreatePostAt

可达性是二次竞争,但仍会重现同类 fail-open:new-topic scratch 输后 prepared 携完整 seed+follow-up;接管 winner 在处理前消失,进入 auto-create,此时 autoCreatePostAt 仍完整;若 auto-create 又输给新 winner,第二次 prepared handoff 丢掉该集合,最终 winner 只从 data.message 重抽 follow-up,forward seed 的 post @ 再次消失。

最小修复是在 auto-create loser handoff 同样传 postParticipantMentions: autoCreatePostAt。测试应分别锁两处 handoff(或断言赋值出现两次);当前测试只检查 postParticipantMentions: newTopicPostAt,因此没有覆盖第二处。

除此之外该 delta 的提取/消费分层、prepared 空数组通过 ?? 保留、正常 existing/auto-create 回退均核对成立。

验证:

  • pnpm build:通过。
  • 9 个相关测试文件:287/287 通过。

@deepcoldy

Copy link
Copy Markdown
Owner

Pre-rebase 行为复核已收敛:959339b7e 补齐了 auto-create CAS loser 的第二处 postParticipantMentions 透传,两个 handoff 均由测试明确锁定。

本地验证:

  • pnpm build:通过。
  • 9 个相关测试文件:287/287 通过。
  • git diff --check 6d5919723..959339b7e:通过。

当前增量未发现新的 blocking,可以统一 rebase 到最新 master。由于 PR 目前存在真实冲突,本次不提前给 approval;请在 rebase 后 head 重跑 build、全量 unit 与相关定向回归,并对冲突文件做语义 diff 复核,随后再进行最终 review。

xiongz-c and others added 11 commits August 6, 2026 21:56
在已修复「话题替身轮保留流式卡」的基础上补齐三处:

1. 彻底修复替身轮误判的根因与残余读取点。此前仅在 isSubstituteTurn
   读取侧对 thread scope 早返,但 footer 寻址(cli.ts)直接读
   pickTurnReplyTarget().substitute,未走 isSubstituteTurn,thread-scope
   替身轮的 footer 仍会从「@owner」漂移到「@last-caller」。改为在写入
   边界 beginReplyTargetTurn 从源头收敛:thread scope 的 per-turn 记录
   只保留 sender 归属(senderOpenId + senderIsBot)与 updatedAt,不落
   chat-scope 专属的 rootMessageId/quoteOnly/substitute。两个读取点
   (isSubstituteTurn 的 scope 门 + footer 的 isChatScope 门)保留为纵深
   防御,兼容历史污染记录。

2. mention-back 参与人数门禁改为按「本轮触发者是不是 bot」不对称放行。
   per-turn 记录新增 senderIsBot(任意 scope 写入,bot→bot 接力也发生在
   话题里),五个入站落点各传其真实值;旧 session 从 quoteTargetSenderIsBot
   在 turnId 精确匹配时回退。bot 触发的回合直接放行 mention-back(且不再
   查群成员数)——「@ 回触发本轮的 bot」由平台事件确定,不必让模型猜
   open_id;人触发的多人会话仍强制显式 --mention。修复多 bot 群里 bot 被
   门禁挡回、模型改选后误 @ 到唯一真人、漏掉原 bot 的问题。

3. 优化 @ 决策提示与 skill 文案:先按内容价值决定要不要 @,再按收件人
   是谁选 @ 方式(就是触发者用 --mention-back;多人/多 bot 会话回复对象
   常非触发者,用 --mention 显式点名)。命令参考、参数契约、需要通知本轮
   sender 的示例保持不变。

验证:pnpm build 通过;reply-target / send-policy / cli-send-hook-context /
daemon-turn-reply-sender-wiring 定向 74 例通过并各自隔离通过;替身/卡片/
路由回归 170 例通过;对旧实现 red-on-old 复核新增断言全部转红(写入边界、
footer、不对称门禁、senderIsBot 归属)。
复审发现的窄口径缺口:startInitialPassthroughSession 内部在四处
(initialPassthroughSender.type、quoteTargetSenderIsBot、beginReplyTargetTurn、
registration-loser handoff)重新按 `parsed.senderType === app|bot` 判定是否
bot,忽略了 caller 已算好的 cross-ref 兜底身份。当飞书 sender_type 缺失/变值
但 isKnownPeerBot 已识别 peer 时,其它 thread 路径都按 bot 处理,唯独冷启动
passthrough 会把本轮落成 human,导致多人群里的 bot→bot --mention-back 仍进
人数门禁被挡,违背 bot 触发直接放行的目标。

修复:新增独立的 reply-attribution 入参 senderIsBot,与 quota 用的
platform-stamped botSender 分开传,不扩大 quota 信任边界。函数内用
resolvedSenderIsBot(优先 caller 传值,缺省再回退 sender_type)统一四处判定。
两个 caller 分别传 cross-ref 解析后的身份:new-topic 传 isForeignBotSender,
thread 传 isBotSenderType || isForeignBot。

验证:pnpm build 通过;daemon 接线 source 断言更新并通过(含冷启动 resolved
优先级 + 两 caller 传值);reply-target/send-policy/cli-send-hook-context/
daemon-wiring 定向 75 例通过;session-lifecycle-start 等 daemon 回归 120 例通过。
…IsBot

补 c5032b4 漏更新的既有断言:initial-passthrough-ownership 仍要求
registration-loser handoff 包含旧的 `senderIsBot: parsed.senderType === app|bot`,
而源码已正确改为复用 caller-resolved 的 resolvedSenderIsBot。同步断言并注明
loser handoff 与主路径一样复用 caller-resolved attribution(cross-ref 兜底身份)。

验证:pnpm build 绿;全量 unit 12977 passed(仅 doc-comment / group-join 两个
既有满负载 import-hook 超时 flake,隔离复跑 31/31 绿,与本改动无关)。
按产品决策调整 --mention-back 策略:不再按「群成员总数 + 触发者是否 bot」的
不对称硬门禁拦截,改为按「本轮窗口内的 distinct 参与者数」判定歧义——只有本轮
恰好一个对方时 --mention-back 才明确放行;出现 2+ 个对方(一个人 + 一个 peer
bot、两个人、触发者又 @ 了别人、type-ahead 追进来第三方等)就拦下 --mention-back,
并在错误里列出本轮每个候选的 open_id + 名字 + 人/bot,让模型直接 --mention
<open_id> 精确点名(治「被拦回后拿不到/拿错 open_id 而误 @ 唯一真人」的根因)。

实现:
- ReplyTargetEntry 去掉临时的 senderIsBot 布尔,改存 participants:TurnParticipant[]
  (openId + name + isBot),随轮持久化,任意 scope 都写(bot→bot 接力也发生在
  话题里);chat-scope 专属的 rootMessageId/quoteOnly/substitute 仍只在 chat 落盘。
- daemon 五个 begin 落点用 buildTurnParticipants(sender + parsed.mentions,去掉
  bot 自己,mention 经 isKnownPeerBot 标注人/bot) 计算本轮参与者。
- type-ahead 覆盖:每条折叠消息各自落一条 per-turn 记录,collectTurnWindowParticipants
  在 TURN_WINDOW_MS(90s) 窗口内对同轮 sibling 记录做并集,模型把 BOTMUX_TURN_ID
  解析到批次里任一条都能看到完整参与者集。窗口是刻意的近似:偏宽只会多列候选/
  多要一次显式 --mention,永不会误自动 @ 错人(fail-safe)。
- cli.ts 用 mentionBackAmbiguity + mentionBackAmbiguityError 做门禁,读持久化窗口,
  不再调 getGroupStats(无网络往返);显式 VC 轮有单一 origin,跳过门禁。
- 保留 Part1(thread-scope 替身保留流式卡的写入边界根治 + isSubstituteTurn/footer
  scope 守卫 + sender 按轮绑定)与文案解耦;回退上一版基于 senderIsBot 的不对称门禁。

验证:pnpm build 绿;reply-target/send-policy/cli-send-hook-context/
daemon-turn-reply-sender-wiring/initial-passthrough-ownership 定向 85 例通过并各自
隔离通过;bridge/dashboard/listener/transfer 等相关 79 例通过;全量 unit 12979
passed(仅 doc-comment/group-join 两个既有满负载 import-hook 超时 flake,隔离复跑
31/31 绿);对旧实现 red-on-old 新断言(mentionBackAmbiguity / 候选文案 /
collectTurnWindowParticipants 窗口并集与窗外排除)全部转红。
复审指出「窗口近似只会偏宽、永不漏禁」的保证在几处部分缺失路径不成立,逐条收口:

1. **非 open_id 形态的 @ 不再漏计(原漏放行)**:app_id / user_id / union_id 形态
   的 @(parser 让 openId=undefined)此前被 buildTurnParticipants 直接跳过,
   于是「真人 + @其它bot(app_id)」被误算成单人放行。现在这类 @ 不塞进候选列表
   (没有可执行的 open_id 就不冒充),但把该轮标记 participantsIncomplete → 门禁
   按歧义处理,要求显式 --mention/--no-mention。本 PR 不强行把 app_id 解析成
   open_id(留作后续增强)。

2. **空窗口 / 部分裁剪不再 fail-open**:collectTurnWindowParticipants 返回
   `{ participants, incomplete }`。incomplete 置位条件:无 anchor 记录(升级前
   发起的旧轮、或 anchor 已被淘汰)、窗内任一记录自带 incomplete、或裁剪水位
   落进本轮窗口。任一 incomplete → 门禁要求显式决策,不再因「窗口空 → 0 人 →
   放行」而在 sender 仍可解析时绕过。

3. **有界 map + 裁剪水位(不引入无界增长)**:新增 pruneReplyTargets 共享 helper,
   两个 replyTargets writer(beginReplyTargetTurn + trigger-final-suppression 的
   inheritTriggerReplyAnchor)统一走它;淘汰 entry 时把 session.replyTargetsPrunedThrough
   抬到「被淘汰 entry 里最新的 updatedAt」。水位滑出 anchor-90s 后新轮自然恢复
   complete。删掉 trigger-final-suppression 里重复的 REPLY_TARGETS_MAX=32。

4. **候选带姓名(best-effort)**:sender 候选带上已 resolve 的 name(new-topic /
   auto-create / initial 传入);existing-session 热路径 sender 在屏障后才 resolve,
   保持 best-effort 不阻塞入站,姓名缺失时候选仅列 open_id。

5. **is-bot 三态**:true=bot(已知 peer 或 sender 平台盖章)/ false=人(sender 明确
   人类信号)/ undefined=未知;mention 的 isKnownPeerBot=false 不反推为人(第三方
   bot 不在 cross-ref),错误文案显示「未知」。

6. buildTurnParticipants 的纯核提取为 reply-target.ts::buildTurnParticipantsFrom
   (注入 selfOpenId + peer-bot 判定),可独立单测;注释改纯技术表述。

验证:pnpm build 绿;reply-target/send-policy/cli-send-hook-context/daemon-wiring/
initial-passthrough-ownership/trigger-final-suppression 定向 108 例通过并各自隔离
通过;全量 unit 12992 passed(仅 doc-comment/group-join 两个既有满负载 import-hook
超时 flake,隔离复跑绿);red-on-old:app_id→incomplete、空/裁剪窗口 fail-safe、
水位内外、三态未知、incomplete 错误列已知候选等新断言对旧实现全部转红。
复审第二轮抓出「incomplete 闭环」4 处正确性缺口 + 1 处持久化缺口,逐条修:

1. **旧 schema anchor 假 complete**:升级前发起、在执行中的轮,记录只有
   `{updatedAt, senderOpenId}`、无 participants 字段。collectTurnWindowParticipants
   原来只在 `!anchor` 时置 incomplete,这类记录会收集到空集且 incomplete=false →
   放行。现改为:窗内记录有 senderOpenId 但无 participants 字段时,把 senderOpenId
   合成 best-effort 候选并置 incomplete(旧记录没存 mentions,可能漏 counterpart)。

2. **app_id 形态 self @ 误判 unresolved**:isBotMentioned 靠 app_id 认自己,
   而 buildTurnParticipantsFrom 原来只按 openId 排除 self。普通「@当前bot 帮我」
   的 self @ 是 app_id 形态(openId 空)会命中 `!openId → incomplete`,把正常
   1v1 误拦。现在 LarkMention 保留 appId(parser 填充),helper 收 selfAppId,
   先按 app_id 排除 self,再把其它不可解析 @ 标 incomplete。

3. **sender 身份缺失假 complete**:真实入站但 sender 无可用 openId(如 app_id-only
   bot sender 经 realtime/message-listener 路由)时,helper 原来静默跳过 → 与有
   openId 的 type-ahead sibling 同窗时会少算参与者。现在 sender 无 openId 即置
   incomplete(sender 永不是应答 bot 自己)。

4. **sender bot/人 三态被压成 boolean**:daemon 落点原来传 isForeignBot*(boolean,
   未知时为 false)→ 未知 sender 候选显示「人」。新增 senderIsBotTriState 把「是否
   foreign bot 的路由 boolean」与「展示三态」分开:平台 app/bot 或 peer→true、
   senderType==='user'→false、其它→undefined;initial passthrough 另用三态局部量
   避免 `undefined ?? false-expr` 把未知压成 false。

5. **inheritTriggerReplyAnchor 水位未持久化**:synthetic trigger 的 anchor + prune
   水位此前只改内存,独立 botmux send 进程从磁盘读不到。现在 armLoudFinalSuppression
   在 arm 后 sessionStore.updateSession,让 synthetic anchor 与水位落盘。

验证:pnpm build 绿;reply-target/send-policy/cli-send-hook-context/daemon-wiring/
initial-passthrough-ownership/trigger-final-suppression/message-parser 定向 242 例
通过并各自隔离通过;全量 unit 12997 passed(仅 doc-comment/group-join 两个既有
满负载 import-hook 超时 flake,隔离绿);red-on-old:旧 anchor→incomplete+候选含
sender、app_id self @ 的 1v1 不误判、缺失 sender→incomplete、三态未知等新断言
对上一版全部转红。
复审第三轮抓出 2 个 fail-open blocking + 1 个 @ALL 边界,收口:

1. **post 富文本 inline @ 绕过参与者窗口(fail-open)**:post 消息的 @ 常不在
   message.mentions[] 里,而是 content 的 inline `at` 节点(realtime @-gate
   isBotMentioned 本就扫这些);此前 buildTurnParticipants 只看 parsed.mentions →
   「真人 + @Otherbot」被当单人放行。新增 message-parser 的 extractPostAtParticipants
   把 post inline `at` 抽成 routing-only LarkMention(ou_→openId、cli_→appId、
   user_name→name),仅供参与者窗口消费,不污染通用 parsed.mentions(避免波及
   prompt 渲染 / stripLeadingMentions / mention hints)。buildTurnParticipants 收
   可变参 postMessages,把当前消息 + forward seed 消息的 post-@ concat(不走
   key/name 合并)进窗口;new-topic 传 data.message + ctx.forwardSeedData.message,
   existing/auto-create 传 data.message。

2. **mergeMessageMentions 未贯穿新增 appId(fail-open)**:app_id-form @ 无
   open/user/union id,identity 落到 key+name,而 key 每条消息重编号(@_user_1…),
   同名不同 appId 的两个 bot 被折成一个、第二个被当 self 吃掉。identity 优先级加
   `app:${appId}`,字段富化合并加 `appId: existing.appId ?? mention.appId`。

3. **@ALL 被当可执行候选**:`all`(structured `{open_id:'all'}` 或 post inline
   `user_id:'all'`)会被建议成非法 `--mention all`。在 participant core 加
   executable-domain 校验:候选 open_id 必须 `ou_` 前缀;`all` 及任何非 `ou_`
   伪 id 不进候选、置 incomplete。一处同时收口 text(structured)与 post 两条 lane。

验证:pnpm build 绿;message-parser/forward-followup-content/reply-target/
send-policy/daemon-wiring/cli-send-hook-context/initial-passthrough/
trigger-final-suppression 定向 254 例通过并各自隔离通过;全量 unit 13005 passed
(仅 doc-comment/group-join 两个既有满负载 import-hook 超时 flake,隔离绿);
red-on-old:post-@ 提取、同名不同 appId 不合并、@ALL 不进候选 + incomplete、
非 ou_ sender→incomplete 等新断言对上一版全部转红。
复审第四轮抓出最后一个 fail-open:new-topic 注册竞争(CAS)输了时,走
handleThreadReply(prepared) 由 winner 重绑本轮,但 winner 只从 follow-up 的
data.message 现抽 post inline @,seed 的 post @(随 losing scratch rollback 关掉)
丢失。「seed post @Otherbot + follow-up 仅 @self」一旦注册竞争输,窗口又塌成单
sender complete → --mention-back fail-open。

修法(窄方案,传已抽好的 mentions 而非 raw message,避免 prepared 依赖
ctx.forwardSeedData 生命周期):
- PreparedThreadReply 加 `postParticipantMentions?: LarkMention[]`。
- new-topic 用 collectPostAtMentions(data.message, ctx.forwardSeedData.message) 一次
  抽好,既喂 winner 的 buildTurnParticipants,又在 CAS-loser handoff 显式带上。
- existing-session / auto-create window:prepared 时用 prepared.postParticipantMentions
  (race 拿到完整 seed+follow-up 集),否则回落到从 data.message 现抽(普通 follow-up
  路径不变)。
- 拆出 collectPostAtMentions(提取)与 buildTurnParticipants(消费 postAtMentions
  入参)两职责,让 prepared 携带的是已提取结果。

范围说明:本 PR 保证的是「已路由到 daemon 后」participant lane 正确;一个既有的
纯 inline cli_self 路由门不一致(messageMentionsBot/mentionsAnotherMember 的 inline
分支只比 botOpenId 不比 larkAppId)是 PRE-EXISTING,另笔处理,本 PR 不扩面。

验证:pnpm build 绿;daemon-wiring/reply-target/message-parser/forward-followup-content/
send-policy 定向 231 例通过并各自隔离通过;全量 unit 13006 passed(仅 doc-comment/
group-join 两个既有满负载 import-hook 超时 flake,隔离绿);回归锁 prepared 带
postParticipantMentions 类型 + loser handoff 显式传 + existing/auto-create prepared 时
用它。
上一笔只给 new-topic CAS loser 的 prepared handoff 带了 postParticipantMentions;
auto-create CAS loser(handleThreadReply 内的第二次 prepared handoff)漏了。
双重竞争下(new-topic scratch 输 → 进 auto-create → auto-create 又输给新 winner)
第二次 handoff 丢掉完整 seed+follow-up 集,最终 winner 只从 data.message 重抽
follow-up,seed post @ 再丢 → 同类 fail-open。

补 `postParticipantMentions: autoCreatePostAt` 一行(autoCreatePostAt 本身已按
prepared?.postParticipantMentions ?? 现抽 解析,故双重 race 链完整透传)。测试锁成
两处 handoff(计数=2),不再只断言 new-topic 一处。

验证:pnpm build 绿;daemon-wiring 定向 7 例通过;全量 unit 13006 passed(仅
doc-comment/group-join 两个既有 baseline flake,隔离绿)。
@deepcoldy
deepcoldy force-pushed the codex/fix-turn-bound-mention-back branch from 959339b to d5d26c1 Compare August 7, 2026 05:02

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

已在 rebased head d5d26c14c6585070320cb793886e654941adce3e 完成最终检查,结论:APPROVED,无 blocking。

核对结果:

  • range-diff:11 个提交中 9 个补丁等价;其余 2 个差异仅为接入最新 master 的预期变化。cli.ts 冲突分辨同时保留了 master 的 resolveReportPlacement / resolveReportRecipient 与本分支的 collectTurnWindowParticipants,对应 report 与 send 路径均未丢失。
  • 总 diff:per-turn sender/participants 写入、thread-scope substitute 卡片与 footer 隔离、90s participant window、prune 水位、旧 schema / 非 executable mention 的 fail-toward-explicit、post inline @、两处 registration-race handoff 均闭合;chat/thread、普通/替身、冷启动 passthrough、trigger 与 p2p 分支的边界一致。
  • 影响面:修改集中在 Lark 公共路由与 botmux send mention-back 决策;未改变其它 CLI / backend 的输入输出协议。thread-scope 仅持久化 sender/participants,chat-scope routing metadata 不再外溢。

验证:

  • git diff --check origin/master..d5d26c14c:通过
  • pnpm build:通过
  • reply-target / parser / forward / daemon wiring / passthrough / trigger / send-policy / cli-send 定向:9 files,287 passed
  • dispatch / cli-send-dispatch / VC send-policy(覆盖 rebase 冲突邻接路径):3 files,140 passed
  • GitHub CI:build + 全量 unit/workflow-core 全绿;CodeQL actions / JavaScript-TypeScript / Python 全绿;PR 为 MERGEABLE、0 behind master。

已知的 post inline app_id 自 @ 入站路由边界是既有行为,未由本 PR 引入,不阻塞本次合入,建议另行处理。

@deepcoldy
deepcoldy merged commit 725580d into master Aug 7, 2026
6 checks passed
deepcoldy added a commit to xiaoxueSunn/botmux that referenced this pull request Aug 7, 2026
master 前进到 7ec6d4b(含 deepcoldy#775/deepcoldy#730/deepcoldy#750/deepcoldy#762),与 deepcoldy#597 二次冲突 6 文件。
本 worktree merge origin/master 解冲突(admin-squash 时拍平)。

6 文件逐点取舍:
- core/reply-target.ts: union 两套并存的 per-turn 记录——deepcoldy#597 turnReplyContexts(frozen dispatch target)+ deepcoldy#750 replyTargets(mention-back 参与者窗口)。dedupeParticipants/buildTurnParticipantsFrom/collectTurnWindowParticipants/frozenReplyContextForTurn 全保留。
- cli.ts: replyTargets 类型取 master 更全版 + 保 deepcoldy#597 codex-app 字段;flash footer 取 master 对象式(deepcoldy#762),删除会话用 result.mode(非 master 误用的 result.via);replyTargetSenderOpenId 回退链 union:VC → deepcoldy#597 frozenTurnDispatch → deepcoldy#750 turnReplyTarget.senderOpenId → legacy。
- daemon.ts: 三处 registration-race 取 deepcoldy#597 结构化 claimNewDaemonSession + routeToCanonicalOwner/handleThreadReplyAdmitted;destructure union routeToCanonicalOwner + senderIsBot。修真实回归:CAS-loser 经 handleThreadReplyAdmitted 重算 post @s 只读 data.message 漏 ctx.forwardSeedData → 双 race 丢种子转发 post @。两处重算补 forward-seed 参数(codex 合并不变量deepcoldy#1)。
- 3 测试文件: import union;initial-passthrough-ownership 取 deepcoldy#597 claim 断言;cli-send-hook-context + daemon-turn-reply-sender-wiring 迁写到 deepcoldy#597 模型(forwardSeed 重算)并保 deepcoldy#750 mention-back 断言并存。

验证: pnpm build✅; 受影响套件全绿(reply-target-fallback 41/daemon-turn-reply-sender-wiring 7/cli-send-hook-context 14/send-policy 46/initial-passthrough-ownership 8/daemon-rename-route 55/command-handler 235/transfer-session 71/dashboard-create-session 41/trigger-session-root-message 41/restore-zombie-close 33/scheduler-silent-execute 24/session-resume 39); deepcoldy#597 核心 steer worker-routing 8+bridge 66+dispatch 6+transfer-gate 2+initial-user-turn 28。codex-app-runner 1 项既有 bounded-history flake 无关。
deepcoldy added a commit to xiaoxueSunn/botmux that referenced this pull request Aug 7, 2026
…lback 打穿(codex 二轮扫尾 blocking)

合并二轮时我把 replyTargetSenderOpenId 回退链写成无条件
`?? s.quoteTargetSenderOpenId`,重新打穿了 deepcoldy#750 的 exact-turn 契约:
有 currentTurnId 时,若精确轮 A 的 replyTargets 已淘汰/缺项(turnReplyTarget
undefined),而全局 quote sender 已推进到 B,最后一跳会把 B 当作 A 的
--mention-back 目标,重新引入 deepcoldy#750 刚修的串轮 bug。

修:legacy 全局 slot 仅在**无 currentTurnId**(真正 legacy/no-turn 发送)时
才可回退;有 turnId 的 map miss/eviction 必须解析为无 sender、绝不借全局 slot
(它可能指向不同的轮 B)。pickTurnReplyTarget 自身命中已强制
quoteTargetId===currentTurnId。

测试:cli-send-hook-context 加精确 source guard——断言 gated 形式
`?? (currentTurnId ? undefined : s.quoteTargetSenderOpenId)` 且**禁止**任何
无条件 `?? s.quoteTargetSenderOpenId;`。mutation 复原无条件回退 → 翻红。
cli-send-hook-context 15/0、send-policy 46/0、reply-target-fallback 41/0、
daemon-turn-reply-sender-wiring 7/0;build✅。
deepcoldy added a commit that referenced this pull request Aug 8, 2026
* fix(codex-app): make turn ownership and recovery durable

* test(codex-app): cover same-admission cap sweep

* fix(codex-app): 修首审 2 个 P2(Lark upload 超时 + restore 孤儿行)

P2-1 Lark upload 超时:#597 给全 bot/全 API 加的 15s SDK 超时对文件/视频
上传过窄(uploadImage/uploadFile 走同一 client 且无重试,~19MB@10Mbps 就
会被截断)。官方 SDK 的 httpInstance 是模块级共享单例、typed 上传方法无
per-request 超时钩子,故给上传单独一个 http 实例(defaultHttpInstance.create
+ 复制 UA/response-unwrap 拦截器)+ 120s 超时;交互调用仍保持 15s。worker 侧
lark-upload.ts 同款处理。SDK 不再导出 defaultHttpInstance 时 fail-safe 回退到
共享实例(上传退回 15s,不 brick)。

P2-2 restore 孤儿行:restoreActiveSessions 里 activation-tail 提升失败时原本
throw → 落进 per-row 隔离 catch 但未注册 → 该行盘上 active 却不在 activeSessions
(IM /close 够不到 + 同 anchor 再来消息会建第二条 active 行,老 tail 永久悬挂)。
改为不 throw:注册成可见的 quarantined owner(未提升 tail 保持 protected 占住
anchor、可 /close、下次激活自然重试提升)。提升失败仅在 send:false 的瞬时持久化
错误时发生,故 quarantine + 重试是自愈的。

测试:
- bot-registry:新增上传专用 http 实例断言(交互 15s vs 上传 120s、独立实例、
  共享 default 未被污染);FakeClient/mock 补 defaultHttpInstance 镜像真实 SDK
- session-resume:新增 restore 瞬时提升失败注册 quarantined owner 回归用例
  (变异测试验证:改回 throw 则该用例 FAIL,证明对实现敏感)
- pnpm build 绿;P2 相关 8 套件 264 用例全绿;7 个 SDK-mock 套件 147 用例全绿
  (defensive 回退生效)

Co-Authored-By: Riff

* fix(codex-app): 收口 codex 复审两点(upload fail-safe 真回退 + quarantine 自愈)

采纳 codex 二次复审的两个 P2:

1) worker upload fail-safe 失效:lark-upload.ts 用 named import
   `defaultHttpInstance`,SDK/mock 缺该 export 时在 fail-safe 判断前就抛
   (vitest 对缺失 named binding 的访问直接 throw)。改成 `import * as Lark`
   + 防御式 namespace 访问(try/catch,mirror bot-registry),缺失时得
   undefined→回退共享实例而非 brick。test/lark-upload.test.ts 原 3 失败转绿。

2) restore quarantine 不自愈:promotion 首次失败后 initialStartPending 恒
   true,tryAcquireInitialStartClaim 因此拒绝认领冷 owner,后续消息只
   admitQueuedActivationTail 追加+return,promote 永不重试→session 卡到
   /close。修:quarantine 时强制 initialStartPending=false,使下次入站能
   claim→fork→forkReservedInitialSession 重新 derive gate 并
   releaseQueuedActivationReservation→promoteQueuedActivationTail 排空 tail
   (新消息按 reservation 排在其后,不错序);持久后端 restore 亦经 toReattach
   重 fork。promote 成功路径不受影响(gate 该留则留)。

测试(均变异验证对实现敏感):
- lark-upload:补 defaultHttpInstance mock 镜像真实 SDK + 上传实例 120s 断言;
  ⭐并保留独立的 missing-export 用例(vi.doMock 去掉 export)断言回退到普通
  Client 且不抛——不让修 mock 抹掉本 bug 的触发条件(codex 要求)
- session-resume:quarantine 用例加断言 initialStartPending===false(自愈
  enabler);新增 promote 成功时 initialStartPending 仍 true 的反向用例(防
  自愈修法过度反应)
- promote 的 retry(false→true)+ FIFO ordering + tokened journal 由既有
  session-lifecycle-start 用例覆盖
- build 绿;P2 相关 8 套件 233 用例全绿

注:PR 当前对最新 master(24226c8)CONFLICTING,但冲突文件(cli/daemon/
worker/types/trigger-session/dashboard-ipc/worker-pool)与本次 P2 修复文件
(bot-registry/client/lark-upload/session-manager)不重叠——是 #597 原本 vs
#583/#281 的既有撞车,需单独 rebase,与这两个 fix 无关。

Co-Authored-By: Riff

* fix(codex-app): quarantine 自愈改为 fork 边界前置 promote-retry(采纳 codex 三审)

上一版"清 initialStartPending gate"是错的(codex 三审指出+我核实):
- toReattach 空 fork 不 promote → 事后 live worker+未提升 tail,入站看到 live
  worker 跳过 claim、只追加 tail,promote 永不重试;
- 冷路径清 gate 后新消息作普通 refork prompt 先发 → 越过老 tail,FIFO 反转。

采纳 codex 硬约束:任何 blank/current-turn fork 前,tail-only quarantine 必须
先 retry 老 head 的 promotion;失败就保持占 owner/gate、跳过 fork,绝不让当前
turn 越过、也绝不留 live-worker + 未提升 tail。

实现:
- 新增运行时标记 DaemonSession.quarantinedActivationTailPromotion(restore 时
  promote 失败置位,不再清 gate,initialStartPending 保持 true)。
- 抽 `retryQuarantinedActivationTailPromotion(ds)` 共享 helper:非 quarantine→
  true(no-op);promote 成功→清标记返 true(可 fork 已提升的 tokened head);
  promote 仍失败→返 false(调用方跳过 fork,保持 worker:null 占位)。幂等
  (promoteQueuedActivationTail 对已 pending 短路 true)。
- 两个 fork 边界前置调用:
  1) restore toReattach 空 fork 回调:helper 返 false 则 return 跳过 forkWorker;
  2) daemon 入站冷路径:当前 turn 已按 reservation 追加到老 tail 之后,再 retry;
     成功→forkReservedInitialSession 冷 fork 已提升的老 head(非当前 turn);
     失败→当前 turn 留 tail 等下次,保持 quarantine。

测试(session-resume.test.ts):
- restore quarantine 用例改断新契约:initialStartPending 仍 true(gate held)+
  quarantinedActivationTailPromotion=true(待 fork 边界 retry),不再断错误的
  gate=false;反向用例(promote 成功 gate 仍 true)保留。
- 新增 helper 三用例:成功→true+清标记+promote 以 send:false 调用;失败→false+
  保留标记(调用方必跳 fork);非 quarantine→true 且不碰 promote。⭐失败路径变异
  测试验证对实现敏感(fail→true 变异使用例 FAIL)。
- retry(false→true)+FIFO ordering+tokened journal 由既有 lifecycle 用例覆盖。
- build 绿;P2 相关 8 套件 236 用例全绿。

CONFLICTING(master→24226c87,与 #583/#281 既有撞车,不涉本次文件)待 rebase。

Co-Authored-By: Riff

* fix(codex-app): sink quarantine tail-recovery into forkWorker central guard

采纳 codex 四审:把「tail-only quarantine 恢复」不变量从逐 fork 入口
补丁下沉到 forkWorker 一处中央守卫 resolveQuarantinedForkPlan,收口
P2-A 与 P2-B。

- 中央守卫(forkWorker 顶部,早于任何 prompt 派生/session 变更):
  · 非 quarantine → 原样放行
  · quarantine + 非空 prompt → 拒绝(返回 false),调用方须先把该 turn
    durable-admit 进 tail 再空 prompt 恢复,杜绝越过老 head
  · quarantine + 空 prompt → fork 前 retry promoteQueuedActivationTail;
    失败则保持 flag/gate/worker:null 并拒绝(绝不留 live worker + 未提升
    tail);成功则清 flag 并按 CLI 改写为「恢复老 head」的精确参数
    (Codex App 走 ledger + 空 prompt;非 Codex 重发 queuedActivationInput
    + resume/turn/attempt)
- forkWorker 返回 boolean(此前 void;无调用方读取返回值,纯向后兼容)
- P2-A:daemon 入站分支不再调 forkReservedInitialSession(会用空
  pendingPrompt 造 synthetic opening envelope 被当新 dispatch 越过老 head),
  改为经中央守卫的空 recovery fork
- P2-B:ensureTerminalWorkerPort 懒唤醒检查守卫返回值,拒绝时返回
  unavailable 而非空挂 10s(此前完全未加守卫的第三个 blank-fork 入口)
- 删除旧的逐入口 helper retryQuarantinedActivationTailPromotion(三处调用
  统一收敛到中央守卫),toReattach/restore 注释同步更新

测试(真实 forkWorker + 真实 promoteQueuedActivationTail,仅 mock 子进程
fork/session store,覆盖本次 wiring 而非仅 helper 返回值):
- 新增 session-lifecycle-start「quarantined tail-only owner recovery at the
  fork boundary」:拒绝非空 / retry 失败 0 fork 保 gate / retry 成功恢复老
  head(Codex App 走 ledger 空 prompt + 非 Codex 发 queuedActivationInput,
  断言不含 <user_message> envelope)/ 非 quarantine 纯放行
- session-resume 旧 helper 单测下沉说明,restore 侧仍守 flag 契约

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(codex-app): runner ordered pre-final steer driver (Blocking 1, runner 侧)

在 runner 内实现「一个 native turn 携带有序 accepted 组、完成时展开成 N 条签名 final」
的插话驱动器,恢复 master #588 的 ordered-steer 语义并迁到签名 socket,且不破坏
#597 的 Goal/reconcile/response-last 机制:

- finalizeAcceptedGroup:accepted 组展开成 N 条 final,前 N−1 条 steer_superseded
  (空内容/无 usage/仅推进 worker FIFO),末条真回复+usage;N=1 时与旧单-final 契约字节等价。
- tryAdmitSteer + canSteer:普通 Lark inbound(codexAppSteerable===true)在 root native
  turn 打开后机会式 turn/steer 插入同一 native turn;queue head 只在 steer 被接受后 shift。
- 完成栅栏:见 canonical turn/completed 先关 steering,若 steer RPC 在飞则 buffer
  (completion_race)等其结算后再展开;startResponsePending 与 steerInFlight 分离字段。
- fenceUnknown:未知 turn/start|turn/steer 结果发签名 unknown_outcome+fatal 并 phase=fenced,
  零 final(worker 收 fatal 调 failCodexAppControlGeneration);definite rejection 不 shift/不 append。
- 补齐 runner 的 signed lifecycle 发射(steer_attempt/steer_accepted),接回 worker 既有
  但此前是死路径的消费端。

测试:新增贯穿用例——input#2/#3 在 final#1 前到达并被 ordered steer 接受,断言
turn/start+2×turn/steer、3 条 final(2 superseded+1 real)、2 对 steer lifecycle。
runner 集成 32/32 绿(原 31 零回归),tsc + build 绿。

worker superseded 分支 + 防御性 re-check + 剩余验收测试待续(下一 commit)。
Blocking 2(usage 透传)已在同分支修复。

* fix(codex-app): 修 codex delta 复审的 5 blocking + 2 nit(runner 侧)

按 codex delta(head dc0142f)复审逐条修复,红先行 + 变异测试证敏感:

- B3 Goal gate:drainQueue 取 head 前判 codexAppSteerable;native Goal 活跃时
  仅 flag===true 的 head 可作 Goal-steer 进入,missing/false 原地等 Goal 完成后
  独立 turn/start;Goal completion 的 no-active-turn 分支 re-kick drainQueue 防 false
  head 永睡。既有 Goal-steer 测试补显式 flag,新增 goal-autocomplete fixture +
  "非 steerable 不进 Goal、Goal 完成后独立 start" 用例(变异 gate→用例 FAIL 证敏感)。
- B2 fence 真停:drainQueue 见 generationFenced 立即 break、不 shift、不 idle/prompt;
  runTurn catch 见 fenced 不再合成 failure final;turn/start 的 UNKNOWN(timeout/
  transport,非显式 RPC 拒绝)走 fenceUnknown 而非 throw→failure final(-32000 等显式
  服务端拒绝仍走原 failure final 路径,不误 fence)。worker lifecycle 分支补
  authenticated fatal → failCodexAppControlGeneration(此前当 informational 吞掉)。
- B1 start-response-last:发 root start 前置 startResponsePending;exact turn/started
  在 start 响应前即绑 canonicalNativeTurnId + identityProof='exact_started' + phase=open
  并 kick,使 follow-up 可在 start 响应 pending 期间 steer;late start response 先核
  canonical id(不符→fence),再清 pending、重放 buffered completion;pending 未清或
  steer 在飞不得 settle。新增 steer-started-first fixture + 贯穿用例。
- B4 replay-before-kick:runTurn root response 后顺序改为 验证/bind → 重放 buffered
  started/completed(先置 completionSeen/关门)→ 仅 open 时才 kick;补 late-response
  对多成员组的 settle(buffered completion_race 在 startResponsePending 清后结算)。
- B5 group identity:settleSteeredCompletion 在 itemsView:'full' 时校验每个实际发送
  clientId 的成员各恰一次且 index 严格递增,从最后成员 index rebuild;缺/重/乱序
  → reportIdentityConflict fail closed(不再只查最后一个 + 静默回落 streamed text)。
  新增 steer-group-mismatch fixture + "缺成员 fail closed" 用例(变异防御→用例 FAIL)。
- nit1: definite steer rejection 复用 controller 口径(新 isDefiniteRpcRejection,认
  -32600/-32601/-32602 + 显式短语),不再只认 isExplicitExpectedTurnInactive。
- nit2: tryAdmitSteer 里 queue[0]!==head 改 protocol fence(不再静默 append 错成员)。

测试:runner 集成 35/35(原 32 + B3 serial/B1 started-first/B5 mismatch 三新用例)、
worker routing 6/6、protocol+dispatch-ledger 40/40、tsc + build 全绿。B3/B5 变异测试
证对实现敏感。

worker 侧 disposition:'steer_superseded' 消费分支(codex 5 步落法)待下一 commit。

* fix(codex-app): 收口 codex R3 三个组模式基数 blocking + worker superseded 消费

codex delta(head 46dde5c)对 4-way 竞态矩阵复审又抓 3 个 blocking,共享根因=只
canonical completion 路径做了 group-aware,其余 completion/失败路径仍落单-turn 逻辑,
而 finalizeAcceptedGroup 照 accepted[] 展开 N-final → 基数错配。统一修法:引入
inGroupMode(turn) 谓词,组模式下所有 completion/失败路径必须 group-aware。

- R3-B1(组模式失败必 fence):runTurn start-RPC 失败时,只要有 positive evidence
  (serverStarted / identityProof / canonical / accepted>1)一律 fenceUnknown 0 final,
  绝不 outer synthetic single final(否则已 shift 的 follow-up 永久留在 worker FIFO=poison)。
  非 RPC 失败(timeout/transport)无 evidence 也 fence;仅显式 RPC 拒绝且无 evidence 才走
  原 throw→failure final。红测 steer-then-drop(exact-started→steer accepted→start RPC 显式拒绝)。
- R3-B2(exact-completion 原子升级 proof):start response 前到达的 exact-client 完成对
  steerable root 原子升级=绑 canonicalNativeTurnId + identityProof='exact_started' +
  存 candidateCompletions + 要求晚 response 同 id,升级前只 buffer 不 settle;nativeTurnId
  不再可被晚 response 无条件覆盖(不符即 fenceUnknown protocol 0 final)。红测
  completion-before-response-mismatch(A 先到→response B→protocol fence 0 final)。
- R3-B3(组模式全路径 group-aware):inGroupMode 下 canonical 走带 barrier 的
  settleSteeredCompletion,非 canonical/history 走新 reconcileSteeredGroupFromHistory——
  全历史仅唯一"含整组 exact 有序子序列"的 terminal turn 才结算,0/多/仅root match 一律
  fail closed(identity-error 末条绝不携带 foreign model text)。提取 verifyGroupSubsequence
  复用。红测 steer-noncanonical(非 canonical 完成 + history 仅 root-match → 不展开 follower)。

worker disposition:'steer_superseded' 消费(codex 5 步落法):严格校验 disposition
(unknown 拒绝;superseded 必须 content===''&&usage===undefined&&awaitingFinal===true);
durable 与 fallback 两分支都转发带 disposition 的 final_output(空内容+suppressDelivery,
daemon 持久化不 deliver);liveness 移到 commit 后且 disposition-aware(superseded 退 1
逻辑 slot 但保持 awaitingFinal=true 不 publish ready;末条真 final 才清 awaiting;失败不退
slot 不清 awaiting)。types.ts final_output 加 disposition 字段。

另修一个真实缺陷:init 首轮 prompt 的 sendToPty 漏传 codexAppSteerable → codex-app
会话第一轮永远不能吸收 follow-up steer(canSteer 要 accepted[0] steerable)。已补线。

测试:runner 集成 38/38、worker routing 7/7(新增 worker superseded N-final 贯穿)、
其余 codex-app 单元 6 套件 112/112,tsc+build 全绿。R3-B1/B3 + worker superseded 变异测试
证对实现敏感。

* fix(codex-app): 收口 codex R4 四个 blocking(canonical不变性/history权威/首轮steer透传/superseded双重校验)+2 nit

codex delta R4(head be95ab3)复审 4-way 竞态 + 生产贯穿又抓 4 blocking,其中 2 条是
"测试绕过生产接线=假绿"(B1/B4)。逐条独立核实成立,红先行 + 变异测试:

- R4-B1(假绿:首轮 steer flag 只修 worker 半跳):生产 daemon 从不给 init/queued-tail 填
  codexAppSteerable,原 worker.ts 复制的是恒缺字段。修:flag 进 CliTurnPayload frozen
  payload;daemon 在 live/null 分叉前算一次(原来只 live 分支算);COPY 到 tail admit /
  promote / repark / fork accept-ledger / initMsg / worker-null 冷 refork opening;
  系统/queued-dashboard/durable-tail 开场保持 false,不从 sink 反推。生产贯穿测试(不手工
  塞 flag)断言 live sendWorkerInput opts + worker-null forkWorker payload 都带 flag。
- R4-B2(canonical 非 first-proof-wins):exact completion A 绑 canonical 后,后到 exact
  turn/started/completed B 无条件覆盖成 B→A 正文标 B id。修:新 proveCanonicalExact
  first-proof-wins(proof 拆 exact_started|exact_completed,后续不同 id 立即 fence、相同
  幂等),settle 前复核 terminal.id===canonical。红测 completion A→started B 与
  started A→completion B 两条 0 final+fence。
- R4-B3(history 唯一 match 不切 native 权威):reconcileSteeredGroupFromHistory 重建正文却没
  设 nativeTurnId/canonical=matched.id→final 标旧 id、usage drain 错、nativeActiveTurnId
  残留。修:唯一 full-group match 成功以 matched.id 作 final/native/usage 权威,CAS 清旧
  canonical(有更新 Goal C 不清 C)。红测 unique success(权威来自 B + Goal C 存活)。
- R4-B4(假绿:superseded 无双重校验):daemon durable settlement 不读 disposition,任意
  suppressDelivery:true 直接 commit;worker reservation 无 steerable 无法复核 head。修:
  worker reservation COPY steerable + settleFinal 暴露 steerable/remaining,superseded 要求
  head steerable 且 remaining>0(否则 reject,reservation/slot/awaiting 全不动);daemon
  preview 后任何 commit/deliver 前复核 entry.codexAppSteerable===true+sink=lark+无 VC/
  receiver/origin,否则 ACK false 不 pop。红测 daemon 真实 ledger:非 steerable/非 lark head
  拒(ACK false 不 pop)+ steerable lark head with successor 提交(ACK true pop 不 deliver)。
- nit1: candidateCompletions 只写不读→删(first-proof-wins 后 terminalCompletion+canonical
  已是单一权威);nit2: Goal-root steer unknown 的 fence operation 按实际 RPC 标 turn/steer。

测试:runner 集成 41/41、worker routing 7/7、initial-user-turn-opening/bridge-final-output-
retry/dispatch/ledger/protocol 126/126,tsc+build 全绿。R4-B1(冷fork COPY神经化)/B2(fence)/
B3(identity切换神经化)/B4(daemon recheck神经化)变异测试证敏感。

* fix(codex-app): 收口 codex R5 四个 blocking(首轮授权全 admission 分支/COPY链剥离/prepared恢复/daemon双防线)

codex delta R5(head be51997)复审生产贯穿又抓 4 blocking,B1 两条是"授权判定位置错 +
COPY 链主动剥离",B4 两条是"prepared 恢复丢标记 + daemon 防线 fail-open"。逐条独立核实:

- R5-B1-1(授权判定晚于早期 admission 分支):codexAppSteerable 原在 live/null 分叉前算,但
  仍晚于 initialStartPending follower / pending-repo follower / 新建话题 auto-create 等多个
  更早的 admit/fork return。修:判定上移到所有 admit/fork 分支之前(handleThreadReplyAdmitted
  claim 边界后一次算);新建话题首轮冻进 pendingCodexAppSteerable,buildReservedInitialInput
  COPY 到 opening payload——forkReservedInitialSession(bot-added/scheduler/系统 bootstrap 共用)
  只 COPY 显式 true 不推断。早期 tail-admit(initialStart/pending-repo)也冻 flag。
- R5-B1-2(COPY 链主动剥离):admitQueuedActivationTail/promoteQueuedActivationTail exactInput/
  forkWorker durable-owner admit 重建 cliInput 时只挑 content+codexAppInput,剥掉 steerable。
  修:每个重建点 COPY codexAppSteerable(只 === true;codexAppInput 仍 structuredClone 深拷贝
  不共享可变 sidecar)。真实 admit→promote→accept-ledger 贯穿测试断言四处都 true、missing 保持
  serial(变异 admit COPY→FAIL)。
- R5-B4-1(prepared 恢复丢标记):CodexAppTurnDispatchQueue.restore/reserve entry type 不接
  codexAppSteerable + worker prepared prefix restore(worker.ts:13335)没映射→replacement 后
  daemon head=true 但 worker reservation=false→合法 superseded 被 worker:6731 拒→崩溃重放卡死。
  修:restore/reserve/settleFinal 全程透传 codexAppSteerable。单测证 restore 保留+settle 暴露
  (变异透传→FAIL)。
- R5-B4-2(daemon 防线 fail-open 两处):durable settlement 接受 sink===undefined(应强制
  ==='lark',legacy undefined 不当安全 Lark)+ 没复核 successor(preview.ledger.length===0 单头
  forged superseded 仍 commit)。修:sink==='lark' 严格 + 要求 preview.ledger.length>0。补
  真实 3-entry ledger ACK 的 sink-undefined + single-head 两个 ACK=false/no-mutation 负例。

测试口径纠偏(codex 指出):B3 Goal C 断言改"结算后最新 state busy"(原 some(busy=true) 命中
final 前状态);补 grown-group multiple-full-history match fail-closed 负例;补 restore→settle
真实透传单测(现有 worker N-final 走 fallback、daemon bridge 直接 emit,两半没贯穿 prepared 恢复)。

测试:runner 集成 42/42、worker routing 7/7、unit/daemon(opening/bridge/dispatch/ledger/
lifecycle/resume/protocol)262/262,tsc+build 全绿。R5-B1-2/B4-1/B4-2 变异测试证敏感。

* fix(codex-app): 收口 codex R6 三个 B1 生产入口/最后一跳 steer 透传断点 + 完整往返测试

codex delta R6(head 91bad0c)抓 3 个 B1 blocking——都是"授权源/最后一跳"漏 flag,
而非 cliInput payload 重建点(那些 R5 已堵)。逐条独立核实并修:

- R6-B1(主新话题入口 + bare/repo 漏冻结):pendingCodexAppSteerable 全仓唯一赋值原在
  handleThreadReplyAdmitted 的 !ds safety-net(18296),**主入口 handleNewTopicAdmitted 的 ds
  构造从没写**→普通新话题首轮 forced-serial;bare /repo 接管重写 pendingPrompt 时也漏。
  修:抽只基于入站 source facts 的 computeCodexAppSteerable helper(adopt/foreign-bot/
  bot-sender/substitute/grill/listener/vc-receiver/vc-origin),两 twin(handleNewTopicAdmitted
  + handleThreadReplyAdmitted)都调;主 new-topic ds + bare-/repo takeover 只 COPY 显式 true;
  forkReservedInitialSession 共用函数(bot-added/scheduler/系统 bootstrap)仍只 COPY 不推断。
- R6-B2(promote→live worker 最后 IPC 丢 flag):promoteQueuedActivationTail 的 exactInput/
  accept-ledger 已带(R5),但真正 ds.worker.send 的 message(4335)漏→live worker 立即接
  promoted N+1 时 daemon head=true 但 worker reservation=false,合法 superseded 被拒。修:IPC
  message COPY exactInput.codexAppSteerable===true。
- R6-B3(forkWorker direct-route opts 丢 flag):protected ownership 无 activation gate(仅
  unsettled ledger)走 sendWorkerInput(ds, promptPayload,...,{dispatchAttempt})(4655),而
  sendWorkerInput 授权源是 **opts 不是 payload**→true 变 false。gate 分支(R5)已修,direct
  分支漏。修:initCodexAppSteerable 传进 opts。

⭐coalesced 缓冲(releaseQueuedActivationReservation 折叠多条为 1 turn)——codex 裁定**保持
serial 是本 PR 明确 fail-closed 语义,非漏传**(N 条折成 1 prompt/1 ledger/1 final,per-message
身份基数已丢,单 flag 无法安全表达整批;pendingCodexAppFollowUpGateAccepted 是 clean-input
feature gate 非 steer 授权,绝不复用)。本轮不改,遵此语义。

测试:①新话题主入口生产贯穿(daemon-rename-route 真跑 handleNewTopic 不 seed owner):plain-human
codex-app 新话题 opening fork payload 带 flag、bot-sender 保持 serial(变异新话题冻结→FAIL);
②完整 durable 往返(worker-routing):root+2 follow 各带真实 dispatchId→worker persist→daemon
ACK→commit,3 durable final_output(2 superseded+1 real)、turn/start+2×turn/steer、无 wedge——
补上此前 worker-fallback + daemon-fake-emit 两半之间的 hop。runner 集成 42/42、worker routing
8/8、unit/daemon 275/275,tsc+build 全绿。顺手把 R4-B3 两个 history-reconcile 用例 waitFor 从
10s 抬到 25s(bounded round-trip 在 CI 负载下偶发超时)。

* fix(codex-app): 补 forkWorker transfer-gate 分支的 steer 授权透传(R6-B3 同类自查)

自查 R6 教训"授权源通道"(sendWorkerInput 授权读 opts 非 payload)时,枚举全部 5 个
sendWorkerInput 调用点,发现 forkWorker 的 transfer-gate 分支(worker-pool.ts:4546,路由
transfer 在途时把 opening/refork 的 promptInput 经 sendWorkerInput 改道)与已修的 direct-route
分支(4660,R6-B3)同源漏传:opts 只带 dispatchAttempt,steerable 从 true 静默降级 false。
修:gatedPrompt.codexAppSteerable===true 时传进 opts。

其余授权源点复核干净:daemon 4239(doc-watch warmup=系统轮,serial✓)、18517(live admission,
opts 已带✓)、19156(doc-comment sink,serial✓);message IPC 4193(sendWorkerInput live,opts✓)、
4336(promote,R6-B2 已修✓)。

tsc+build 全绿;worker-pool 消费方(lifecycle/resume/dispatch/bridge)204/204 回归绿。

* fix(codex-app): R7 收紧 steer 授权源(正向 humanSender fail-closed)+补两测试闸门

Blocking 1 — computeCodexAppSteerable 从"排除已知非人类源"改为"正向要求真人":
- 新增 humanSender 参数(senderType==='user' 且非 known-peer-bot),排除清单永远不完备,任一未枚举的非 user 源在旧写法下 fail-open;正向门使默认 deny。
- 修 new-topic 侧两处 fail-open:threadGrill 硬编码 false → controlRewrite:!!newTopicGrill(被改写的 /workflow 提示不再可 steer);isForeignBot 折入 isKnownPeerBot 交叉兜底(与 thread 孪生定义对齐)。
- thread 侧同步补 humanSender 正向门。

Blocking 2 — round-trip 拆成两半各自走真实路径:
- worker 半(worker-codex-app-turn-routing):每个成员带 codexAppDispatchId → 真调 waitForCodexAppDaemonPersistence,仅在 ACK 后 commit;断言 3 个 completed terminal 证明 FIFO/liveness 未卡死。
- daemon 半(bridge-final-output-retry):真实 settlement handler 的 ledger preview/store/ACK。

Gate 1 — transfer-gate steer 行为测试(新文件):真实撑开 transfer gate,把 codex-app opening fork 穿过去,断言 buffered→replay 的 worker IPC 与 accepted ledger 同带/同不带 steer 标志。mutation 掐掉 R6-B3 透传 → steerable 用例翻红。

Gate 2 — coalesced-serial 锁定测试 + 注释:pendingCodexAppFollowUpGateAccepted 全 true 时 release 后 tail/ledger/IPC 仍无 steerable(codex ruling:合并 N 条已丢失 per-message 身份,构造上强制串行)。mutation 注入 fail-open 派生 → 翻红。修 bot-sender nit:强制 fork=1,断言不再 vacuous。

验证:pnpm build 通过;R7 相关 8 套隔离全绿(transfer-gate 2/daemon-rename-route 38/bridge-final-output-retry 66/initial-user-turn-opening 28/session-lifecycle-start 94/codex-app-turn-dispatch 6/codex-app-runner.integration 42/worker-codex-app-turn-routing.integration 8);两 gate 均 mutation 验证有牙。

* test(codex-app): R7 delta 收紧 steer 测试闸门(worker awaiting 清除+gate2 三层+known-peer 接线+末 final ACK)

回应 codex R7 delta 复审的 1 blocking + 2 gate + 1 minor,均纯测试硬化(不动源码):

Blocking — worker B2 证明"末 final 清 awaiting + 恢复 ready":
- 原测试只等 3 个 turn_terminal:completed,而 terminal 在 post-commit 无条件发,对 awaiting 清除不敏感(掐掉 codexAppCompletionAwaitingFinal=false 仍全绿)。
- 改为断言 group 真 final 之后出现新的 prompt_ready(awaiting 未清则 signed idle 会 fence "published idle before the required final transaction",永不再 ready),并进一步真送一个 SECOND cycle(新 turn/start om_rt_4 + 一次 steer 关闭 om_rt_5)完成,证明 runner 确实能重新开工。请求日志验第二条 turn/start。
- mutation 掐 worker.ts:6895 的 awaiting 清除 → 测试翻红于该 fence,有牙。

Gate 2 — coalesced-serial 三层×一条与多条:
- 改 it.each([1,2]),release 后同时断三层无 steerable:promoted queuedActivationInput / 对应 codexAppDispatchLedger entry / 实际 worker IPC。
- 两个 mutation 各自有牙:release 派生 fail-open(全 true→steerable)两例全红;仅 ledger 侧强推 true 被 layer-2 断言独立抓红(对应 codex 说的"仅 ledger 误推/replacement restore 后授权")。

Gate 3 — known-peer 生产接线红测(非 helper 真值表):
- seed 真实 bot-openid cross-ref 文件后跑 handleNewTopic:sender_type=user 但 open_id 是已知 peer bot(伪装人类)→强制 fork=1 断 opening 无 steerable;正对照(同 cross-ref 下真人不在表内)断 opening 有 steerable,证明判别器就是 cross-ref。
- mutation 删 daemon.ts:16811/16814 两处 isKnownPeerBot → known-peer 用例翻红、正对照仍绿(证两者独立)。

Minor — daemon 半末 final ACK:
- 补断真实末 final codex_app_dispatch_persisted(ok:true, requestId=settle-grp-2),否则 daemon 漏回末 ACK 时 ledger 仍空+Lark 仍投,但真实 worker 会卡 persistence wait。mutation 掐末 final ACK → 翻红。

验证:pnpm build 通过;R7 相关 8 套隔离全绿(transfer-gate 2/daemon-rename-route 41/bridge-final-output-retry 66/initial-user-turn-opening 28/session-lifecycle-start 94/codex-app-turn-dispatch 6/codex-app-runner.integration 42/worker-codex-app-turn-routing.integration 8),我改的三套各 mutation 验有牙;worker-routing 连跑 2 次 8/8 稳定。codex-app-runner 两个 history-reconcile 用例在 baseline(改动 stash 掉)同样 ~1-2/42 间歇超时=既有 bounded-history round-trip flake,与本改动无关(runner/fixture 均未碰)。

* test(codex-app): 修 R7-B2 请求日志断言竞态(改前缀+末尾断完整序列)

codex R7-delta 复审发现的真实测试时序竞态:加入第二 cycle 后,前半段对 request
log 的 `turn/start`+`turn/steer` 做**恰好 3 条 exact-equals** 即时断言,但
post-group prompt_ready 回调会立刻投第二 cycle,其 turn/start+turn/steer 会
race 进同一 log——20ms polling 返回后再读可能已 4~5 条→间歇翻红。

修:
- 前半段改**前缀断言** `groupTurnMethods.slice(0,3)`——group 的有序 steer
  确定是日志最前 3 条(root start→steer om_rt_2→steer om_rt_3),与第二
  cycle 时序无关;
- 末尾第二 cycle 完整 settle 后(其 final 已到,无竞态),补断**完整确定序列**
  `[start, steer, steer, start, steer]`(group 3 条 + 第二 cycle:fixture 全局
  steerCount≥2 故 held turn 一次 steer 即完成);先 waitFor >=5 再读消除读时序。
- 文字 nit:前缀断言注释「complete final sequence is asserted below」改为准确的
  「asserted at the end of the test」。

验证:worker-routing 全套 8/8;R7-B2 连跑 3×pass=1 fail=0(7 为 -t 过滤 skip);
mutation 掐 worker.ts:6895 awaiting 清除仍翻红在 fence,teeth 不变。

* fix(cli): #750 exact-turn sender 不被 #597 legacy fallback 打穿(codex 二轮扫尾 blocking)

合并二轮时我把 replyTargetSenderOpenId 回退链写成无条件
`?? s.quoteTargetSenderOpenId`,重新打穿了 #750 的 exact-turn 契约:
有 currentTurnId 时,若精确轮 A 的 replyTargets 已淘汰/缺项(turnReplyTarget
undefined),而全局 quote sender 已推进到 B,最后一跳会把 B 当作 A 的
--mention-back 目标,重新引入 #750 刚修的串轮 bug。

修:legacy 全局 slot 仅在**无 currentTurnId**(真正 legacy/no-turn 发送)时
才可回退;有 turnId 的 map miss/eviction 必须解析为无 sender、绝不借全局 slot
(它可能指向不同的轮 B)。pickTurnReplyTarget 自身命中已强制
quoteTargetId===currentTurnId。

测试:cli-send-hook-context 加精确 source guard——断言 gated 形式
`?? (currentTurnId ? undefined : s.quoteTargetSenderOpenId)` 且**禁止**任何
无条件 `?? s.quoteTargetSenderOpenId;`。mutation 复原无条件回退 → 翻红。
cli-send-hook-context 15/0、send-policy 46/0、reply-target-fallback 41/0、
daemon-turn-reply-sender-wiring 7/0;build✅。

* fix(daemon): registration-race loser 重路由传完整 prepared,保住 forward-seed 结构化 @mentions(codex 二轮扫尾 blocking #2)

二轮合并时我只补了 forward-seed 的 post 富文本 @(collectPostAtMentions),
但 registration-race loser 改走 handleThreadReplyAdmitted(data, {...ctx}) 裸重路由
后,seed 的**结构化 message.mentions[]** 仍丢:该函数重新 parseEventMessage(data)
只得到 follow-up 的 structured mentions,new-topic 主路径已 merge 进 parsed 的
forwardSeedMentions 被丢弃 → turn-window under-count → --mention-back 可能错误放行。

修(与 #597 admission 锁兼容,不重进 handleThreadReply 外层锁):
- 两个 loser 点(new-topic ~17012、auto-create ~18646)改为直接调用
  handleThreadReplyAdmitted(data, ctx, prepared),把已合并的 parsed、resources、
  attachments、quotaChecked、resolved sender、postParticipantMentions 继续向
  canonical owner / 下一次 race 传;第二个 loser 也传同一 prepared,不再降 raw。
- PreparedThreadReply.queueAlreadyAppended 由字面 true 放宽为 boolean,loser 传
  false(canonical existing-owner 路径自己 append,不写假事实)。
- 复活了 master 遗留的 PreparedThreadReply 死分支(合并断掉的证据)。

红测(真实双 CAS-loser,structured mention 非 post at):seed @Otherbot +
follow-up @self,CAS 失败重路由到 canonical owner 后,其 replyTargets 参与者窗口
仍含 OtherBot;mutation 去掉 prepared 传递 → 翻红(窗口仅 owner/self,漏 OtherBot)。
daemon-rename-route 56/0、daemon-turn-reply-sender-wiring 7/0、command-handler 235/0、
initial-passthrough-ownership 8/0、reply-target-fallback 41/0、cli-send-hook-context 15/0、build✅。

---------

Co-authored-by: 申晗 <deepcoldy@gmail.com>
Co-authored-by: xiaoxueSunn <noreply@bytedance.com>
Co-authored-by: Claude <noreply@anthropic.com>
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