fix(send): 按轮绑定 mention-back 发送者 - #750
Conversation
deepcoldy
left a comment
There was a problem hiding this comment.
复核 head b3534a779:当前有 1 个 blocking;其余实现未发现新的代码阻塞。
🔴 thread-scope 替身轮会错误关闭 streaming card
调用链成立:
- 话题群替身触发在
event-dispatcher.ts中保留threadscope 与原话题锚点。 - 本 PR 将
beginReplyTargetTurn()的replyTargets[turnId]写入移到 scope 早返之前,因此 thread-scope 轮也会持久化{ substitute: true, senderOpenId }。 isSubstituteTurn(ds, turnId)优先读取该 map entry,于是同一 thread-scope 轮由原先的false变为true。- worker 的
ready与screen_update都携带该入站turnId,并分别经过streamingCardDisabled(ds, msg.turnId);初始卡会被跳过,后续卡创建/PATCH 也会被跳过。 - 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 的字面语义,也可在 streamingCardDisabled 与 streamingCardDisabledFor 两处写 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:当前均通过
b3534a7 to
08bf530
Compare
|
补充修正前一条 review 的修法建议:只在
{
"turnReplyTarget":{"substitute":true,"senderOpenId":"ou_sender","turnId":"om_turn"},
"masterLike":{"sendTo":"ou_owner","cc":[]},
"polluted":{"sendTo":"ou_sender","cc":[]}
}因此更准确的主修应放在写入边界: 建议同时保留两处纵深防御,以覆盖历史/异常污染 entry:
回归测试应分别锁住卡片判定与 footer 寻址两个读取点,并确认 thread-scope entry 仍保留 sender、但不带 |
deepcoldy
left a comment
There was a problem hiding this comment.
增量复核结论: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.type、quoteTargetSenderIsBot、beginReplyTargetTurn(...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
left a comment
There was a problem hiding this comment.
复核通过。此前两项阻塞均已闭环:
- 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
left a comment
There was a problem hiding this comment.
ef92513c3 的主路径和定向测试是绿的,但当前“窗口近似只会偏宽、不会漏禁”的不变量还没有成立,需补以下问题后再合并:
-
参与者窗口缺失时会 fail-open。
collectTurnWindowParticipants()在 turnId/map/anchor/participants 不可用时都返回[],而mentionBackAmbiguity([])把它当成“明确 0 人”并放行。此时pickTurnReplyTarget()仍可能从 per-turn/legacy sender 槽拿到senderOpenId,所以升级中正在执行的旧轮、旧持久化记录等场景会绕过歧义门禁并自动 @。请区分“窗口完整且只有一人”和“窗口未知/不完整”;后者在 group 中应 fail toward explicit--mention/--no-mention。 -
非 open_id 形态的 @ 参与者被直接漏掉。
parseEventMessage()明确让app_idmention 的openId为undefined(test/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 并阻止自动 @,不能把它当成未参与。 -
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
left a comment
There was a problem hiding this comment.
c10c80a55 的共享裁剪 helper + prune watermark 方向是对的,但当前 incomplete 闭环仍有以下缺口:
-
旧 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 交给错误提示。 -
app_id 形态的 self mention 会误拦正常单人轮。 helper 只能按
m.openId === selfOpenId排除当前 bot;parser 对 app_id mention 保留openId: undefined且LarkMention未保存 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 都是支持形态。 -
缺失 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除外)。 -
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
left a comment
There was a problem hiding this comment.
3b2b41517 已修复上一轮列出的旧 schema、app_id self、缺失 sender、展示三态及 synthetic anchor 落盘问题;build 与相关回归均通过。但 participant 输入仍有两处 fail-open,需要继续收口:
-
[blocking] post inline
at未进入参与者窗口。 路由门messageMentionsBot会扫描 post content 中的 inlineat,而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语义。 -
[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 all。all/非 ou_ 值应不进入候选并置 incomplete;该校验应覆盖 text 与 post 两条 lane。
本地验证:
pnpm build:通过。- 8 个相关测试文件:274/274 通过。
- 独立探针:post inline-at 与同名不同 appId merge 两条均能稳定复现上述结果。
当前 PR 仍与 master 冲突;同意先收敛行为,再统一 rebase,并在 rebase 后 head 重跑 build、定向与全量回归。
deepcoldy
left a comment
There was a problem hiding this comment.
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_self 且 message.mentions=[] 时,participant extractor 能识别 self,但 messageMentionsBot inline 分支只比较 botOpenId、不比较 larkAppId,因此消息本身不会路由;mentionsAnotherMember 也会把它误判为别人。建议独立收口,避免本 PR 继续扩面。
deepcoldy
left a comment
There was a problem hiding this comment.
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 通过。
|
Pre-rebase 行为复核已收敛: 本地验证:
当前增量未发现新的 blocking,可以统一 rebase 到最新 master。由于 PR 目前存在真实冲突,本次不提前给 approval;请在 rebase 后 head 重跑 build、全量 unit 与相关定向回归,并对冲突文件做语义 diff 复核,随后再进行最终 review。 |
在已修复「话题替身轮保留流式卡」的基础上补齐三处: 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,隔离绿)。
959339b to
d5d26c1
Compare
deepcoldy
left a comment
There was a problem hiding this comment.
已在 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 sendmention-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 引入,不阻塞本次合入,建议另行处理。
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 无关。
…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✅。
* 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>
背景
在多 Bot 群中,Bot B 收到 Bot A 的消息后显式执行
botmux send --mention-back,预期应 @ 回本轮发送者 Bot A。现状存在两层退化:quoteTargetSenderOpenId存成 session 级单槽;排队或并发轮次到达时,后一轮会覆盖前一轮 sender。botmux send虽然已经按BOTMUX_TURN_ID读取replyTargets[turnId]的回复落点,却仍从上述最新单槽读取 sender,导致“落点来自 A 轮、sender 来自 B 轮”。--mention-back,迫使模型从上下文重新选择--mention。这使原本可由平台事件确定的 sender 退化为模型猜测,表现为偶发 @ 到会话 owner 或其它参与者。修复
replyTargets[turnId],把rootMessageId、senderOpenId、quoteOnly、substitute原子写入同一条 per-turn 记录。botmux send --mention-back只读取当前turnId的 sender;VC 显式 per-turn origin 仍保持最高优先级。quoteTargetId === currentTurnId时,才允许从旧 sender 单槽回退;reply slot 同样要求slot.turnId === currentTurnId。--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 对“无明确路由决定”的保守处理仍保留在原路径。影响面
replyTargetsentry 新增可选 sender,旧数据向后兼容。--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 passedpnpm 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 directorytest/v3-host-execution.test.ts:同类目录清理错误未部署 live daemon;本 PR 不抢占本机全局 botmux checkout,合并前可按需做飞书多 Bot 手工验证。