Skip to content

fix(send): 修复链接校验与轮次重复发送 - #1600

Merged
deepcoldy merged 6 commits into
deepcoldy:masterfrom
Kerminate:fix/link-send-guards
Sep 29, 2026
Merged

deepcoldy merged 6 commits into
deepcoldy:masterfrom
Kerminate:fix/link-send-guards

Conversation

@Kerminate

@Kerminate Kerminate commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

问题背景

部分 Botmux 调用方需要发送带有较长 query 参数的权威链接,例如问题详情链接。链接经过模型生成或正文整理后,可能出现参数被截断、遗漏或改写的情况。

此前 botmux send 会直接发送最终生成内容,无法判断调用方要求保留的完整链接是否仍然存在。一旦链接损坏,Botmux 仍会产生消息、上传文件或发布评论等外部副作用。

此外,同一轮任务在重试、进程恢复或响应状态不确定时,可能重复执行 final 发送,导致:

  • 同一最终回答被重复发送
  • 同一轮出现内容不同的多个 final
  • 文档评论分块重复发布
  • file-only final 同时产生多条消息

根因

  1. botmux send 缺少调用方可声明的内容完整性约束,无法验证关键 URL 是否被原样保留。
  2. final 发送缺少跨进程、跨 dispatch attempt 的持久化幂等记录。
  3. 文件、语音、卡片和文档评论等发送路径没有统一纳入同一套发送保护。
  4. 对响应状态未知的非幂等操作缺少 fail-closed 处理。

修复方案

1. 增加通用链接完整性校验

新增可重复参数:

botmux send --expected-link <url>

发送前会检查每个 --expected-link 是否原样存在于最终可见内容中:

• 仅接受完整的 HTTP/HTTPS URL
• 支持重复传入多个链接
• 不自动拼接、修复或规范化 URL
• 在 TTS、文件上传、联系人查询和消息发送等外部副作用之前完成校验
• 链接缺失时直接拒绝发送

校验覆盖:

• 普通文本和回复卡片
• 自定义卡片的可见正文
• sandbox relay
• 语音发送
• 文件及 file-only final
• 文档评论

• 某一步响应状态未知时 fail closed,避免产生重复评论

通用性与兼容性

该修复不依赖 DACU 或具体业务链接,是通用的发送完整性保护。

调用方需要主动传入权威 URL:

botmux send
--response-kind final
--expected-link "https://example.com/detail"
"详情:https://example.com/detail"

兼容性说明:

• 未传 --expected-link 时,不启用链接完整性校验
• 只有具备 turn 上下文的发送才启用持久化轮次幂等
• 不改变 URL 内容,也不尝试从不完整链接推导完整链接
• sandbox relay 已支持并校验新参数

测试

针对本次改动的 6 个测试文件:

• 87/87 tests passed
• npm run build passed
• git diff --check passed

Node 22 本地全量 unit 测试结果:

• 26,081 passed
• 21 skipped
• 5 failed

其中 4 项已在干净的 origin/master 上复现:

• plugin-mcp-sandbox.test.ts:2 项
• worker-codex-startup-readiness.integration.test.ts:2 项

另有 1 项 dashboard-ipc.test.ts 失败,未涉及本 PR 修改的文件。

@deepcoldy

Copy link
Copy Markdown
Owner

你好,你的 PR #1600 评审群已自动创建,维护者和多个 reviewer 会在群内进行评审讨论。请点击以下飞书群链接申请加入评审群(链接一年有效):

https://applink.larkoffice.com/client/chat/chatter/add_by_link?link_token=b77va4c5-23bc-46e6-859e-319a119fcc44

另外,当前自动拉群名单暂时没能关联上你的飞书账号(自动邀请不可见,所以没能直接拉你入群)。方便的话可以把 GitHub 账号与飞书信息补录到名单文档:https://bytedance.larkoffice.com/wiki/WJ1nwWbtxi89erkNGNbcgkt9nUe ,补录后后续评审会自动把你拉进群。

本条为自动评审流程发出的消息,最终结论以维护者审阅为准,谢谢!

@deepcoldy

Copy link
Copy Markdown
Owner

自动评审初步意见(最终以维护者审阅为准)。整体设计扎实:ledger 的 final 指纹栅栏 + 稳定 bts_ provider UUID + 非幂等序列 in-flight 检查点,主路径(文本/卡片、语音、纯文件、文档评论、sandbox relay)都覆盖到了,新测试经变异验证有效,rebase 到最新 master 后 tsc 与相关 600+ 测试均通过。以下是几点建议:

P2 建议修改

1. 纯文件发送被无条件整体读入内存,>512MiB 附件会直接失败(回归)

src/cli.ts:9804-9816:只要判定为 file-only(无正文 + 单个 --files),就会

  • readFileSync(files[0], 'utf8')(用于 expected-link 校验,即使没传任何 --expected-link 也会读);
  • 紧接着再 readFileSync(files[0])(Buffer,用于指纹)。

改动前纯文件发送是按路径流式上传、从不读内容。现在:

  • V8 字符串上限 512 MiB(buffer.constants.MAX_STRING_LENGTH),更大的文件 readFileSync(path,'utf8') 会抛 ERR_FS_FILE_TOO_LARGE,即「发一个大压缩包」这种以前正常的场景现在必然失败;
  • 30 MiB 二进制实测堆增量约 60 MiB(UTF-8 解码约 2x),再加上第二次 Buffer 读取,峰值约为文件体积 3x。

建议:仅当 expectedLinks.length > 0 时才做 UTF-8 解码读取(此时语义上本就要求文本文件);指纹改为对 createReadStream 增量 hash.update(),两次读取合并为一次(或零次整读)。

2. 文档评论轮的非 final 发送会以隐晦报错失败

src/services/turn-send-ledger.ts:193:executeNonIdempotentSequence 对 kind !== 'final' 直接抛 Non-idempotent delivery sequences require a final response,经 src/cli.ts:10185 包成「文档评论发送失败」exit 1。即文档评论轮里如果模型按默认(progress)先发一条评论,现在会失败且错误信息是内部英文、没有可操作指引。行为收紧(一轮一条权威评论)可以理解,但建议:

  • 在 doc-comment 分支前置校验,用明确中文提示「文档评论轮只允许一条 final 回复,请使用 --response-kind final」;
  • 确认线上模型不会在文档评论轮发 interim 评论(daemon 侧流式卡片是另一条路,理论上不经过这里,建议实测一次)。

3. in-flight 卡死之后没有人工恢复入口(可作 follow-up)

文档评论某分块响应未知时,fail-closed 是对的,但该 turn 之后所有重试都会永远报 delivery of step N is unknown,「Typing」reaction 也会一直保留;ledger 文件名是 sha256 哈希,运维无法直观找到对应文件。建议提供一个 inspect/clear 的 CLI 子命令(或至少文档写明恢复步骤),并评估与现有 per-turn 存储(reply-card 等)一致的 TTL 清扫——目前 data/turn-send-ledger/ 每 turn 一个文件、永久保留。

P3 nit(可不阻塞)

  • ledger key 用的是解析后的目标 sid(src/cli.ts:9867-9869),不是 origin session:跨会话 --session-id 直发(非 relay)同一 turn 的 final 不会互相去重。sandbox relay 会强制 session-id,所以面很窄,但类注释「no other primary route or recipient can mint a second one」的表述比实现更强。
  • 并发的两个发送(preflight 都在首个 final 落盘前通过)仍会各自做图片上传/联系人查询,输的一方在 publication lock 处才被拦下;用户可见消息不重复,仅有重复上传成本。
  • 内容完全相同的重试若额外带了 --files/--urgent,replay 早退(exit 0)会静默忽略这些附加动作,成功 JSON 里也看不出来,建议 stderr/JSON 里注明被忽略。
  • 竞争失败方在 execute() 内被拒时走的是 exit 1(发送失败),preflight 拒绝是 exit 2;同一类「确定性拒绝」建议统一为 2。
  • relay 校验里 v.length > 8192 也返回「must be an http(s) URL」,超长但合法的 URL 报错原因不准确。

验证记录(评审方本地)

  • rebase 到最新 origin/master(3830d8243),无冲突,patch-id 与 PR 原 head 一致;
  • bun run build 通过;
  • 6 个 PR 测试文件 93/93 通过;周边 cli-send / reply-card / bridge / doc-comment / sandbox / deferred-topic / oncall / riff / v3-final-outputs / dashboard-ipc 等共 600+ 测试通过;
  • 对新测试做了 4 组变异(链接闸门、ledger 指纹比较 ×2、doc 分块 in-flight 检查点、relay flag 白名单),均按预期变红后还原。

@deepcoldy

Copy link
Copy Markdown
Owner

第二轮评审(双审收敛,自动评审意见,最终以维护者为准)。首审那条评论(#5882191483)的基础上,复审独立复现了行为对比并发现两处更重的回归;我这边对每条都做了独立探针复现(非复述),双方一致认为以下清单项建议合码前收敛。

合码前建议收敛(4 项)

1. file-only 附件读取回归(首审 P2-1 + 复审补充实证)
src/cli.ts:9804-9816 对 file-only 发送无条件先做一次 UTF-8 整读(readFileSync(files[0], 'utf8')),即使没传任何 --expected-link:

  • 512 MiB 附件:Node 在解码前即抛 ERR_STRING_TOO_LONG(实测 600 MiB 稀疏文件),head 裸栈 exit 1,master 同场景正常;

  • 附件不存在时:head 从这里抛裸 node:fs ENOENT 栈 exit 1(实测),master 是既有中文「文件不存在: ...」(cli.ts 现 10415 行的存在性预检到不了)。
  • 30 MiB 二进制实测堆增量约 60 MiB,再加指纹 Buffer 双读,峰值约文件 3 倍内存。

修法建议:UTF-8 解码严格 gate 在 expectedLinks.length > 0;字节指纹改为 fs.createReadStream 增量 hash(合并掉双读);读取前保留友好的附件存在性检查和中文报错。

2. ledger 把「不同请求」当成「同一请求」返回 success(静默数据丢失)
首审我把它列在 P3,复审实测后升级。实测在 head 树上(rebase 最新 master):

  • send final "X" 之后 send final --top-level --chat-id oc_other "X":0 次 POST,但返回 exit 0、success:true, replayed:true——新目标群永远收不到,调用方无任何错误信号;
  • send final "X" 之后 send final --files a.md "X":0 次请求、0 次上传,仍返回 success——附件静默丢失;
  • 同形还覆盖 --voice、换 --m­­ention 目标。

ledger 注释说 route-agnostic 是刻意设计,「一轮不发第二个 final 到别处」这个意图我们认同;真正的缺陷是 fingerprint 只哈希可见正文文本(cli.ts:9867-9876 + ledger.execute),destination / mentions / 附件与媒体都不在内,导致不同的请求被当作同一个请求的幂等重放并回报成功。修法:

  • fingerprint 纳入目标路由(chat/top-level/quote/doc target)、mention 集合、附件/图片/视频标识(路径+size+mtime 或字节 hash);
  • 「同内容同目标同附件」的真重试维持 replay 成功;「内容相同但路由/附件不同」必须 exit 非 0 + 明确中文原因(如:本轮 final 已投递到另一目标,补充消息请用 --response-kind auxiliary),绝不能返回 success:true。

3. doc-comment:未触达 provider 的失败把整轮评论永久焊死(且默认 send 就硬失败)
实测:executeNonIdempotentSequence 在每次 dispatchStep 之前写 inFlightStep(ledger.ts:218-221);第一个分块在任何飞书请求发出之前因缺 User Token / 参数错误失败后,账本残留 inFlightStep=0,此后任何重试(包括新的 dispatch attempt)都永远报 delivery of step 1 is unknown——普通 token 过期重授即可触发,不限于进程崩溃,且 Typing reaction 也不会被清理。
另外 src/core/doc-comment-prompt.ts:159-187 给模型的指令只说「直接输出答案、不要调评论 API」,从未教 --response-kind final;模型在文档评论轮自然地 botmux send "答案"(默认 progress),现在直接 exit 1 + 内部英文报错。
修法:

  • 区分「确认未触达 provider」(取 token/参数/发出前失败)与「响应未知」:前者清除 in-flight 后允许重试,后者才保留 fail-closed;无法分类时维持现状但必须有 inspect/clear 入口与中文操作指引;
  • doc 轮把不带 kind 的 send 按 final 对待(一轮本来就只有一条权威评论),或前置明确中文报错,不要抛 Non-idempotent delivery sequences require a final response。

4. final 之后的补充发送与给模型的指引自相矛盾
实测 head:send final "X" 后普通 botmux send "补一句"(默认 progress)exit 2、英文 This turn has finished...;而 src/i18n/zh.ts:917 的 ai.send.after_success_hint 仍写着「若还有要发给用户的内容,继续 botmux send」。模型按指引操作必撞墙。修法二选一:保持收紧就同步改引导文案 + 给中文可操作报错(指明补充消息走 --response-kind auxiliary);或回退这一点的 master 行为。请在 PR 描述里写明选择和理由。

follow-up 即可(不阻塞)

ledger 文件按哈希命名、无 TTL 清扫;execute() 内竞争拒绝走 exit 1 而 preflight 拒绝走 exit 2;sandbox relay 对超长 URL 报「must be an http(s) URL」原因不准;并发竞争失败方仍会重复一次图片上传/联系人查询;跨 --session-id(非 relay 手工直发)的 ledger key 只用目标 sid,面很窄但注释「any route or recipient」强于实现。

评审方实测环境

rebase 到 origin/master 3830d82(零冲突,patch-id 980d3882… 与作者 head f5ef740 逐字一致);bun run build 0 错;PR 6 测试文件 93/93;周边 cli-send / reply-card / bridge / doc-comment / sandbox / oncall / riff / v3-final-outputs / dashboard-ipc 等 600+ 全绿;4 组变异(链接闸门 / ledger 两处指纹比较 / doc 分块 in-flight / relay 白名单)均按预期变红后还原;本评论第 1-4 项的回归现象均有 head 树独立探针复现(换目标 0 POST 回报 success、附件 0 上传、600MiB 裸崩、缺失附件裸 ENOENT、step 0 失败后永久卡死、late progress exit 2)。

@deepcoldy

Copy link
Copy Markdown
Owner

补充两条修复收口细节(接续上一条 #5883483858 的门槛 ②③,自动评审意见,最终以维护者为准):

关于 ③:ledger 记录没有任何生命周期清理,卡死会跨会话存活

turn-send-ledger/ 既未接入 session close 清理链(src/services/session-store.ts closeSession 目前只清 turn-sends bridge marker、prompt-ctx、statusline、frozen-cards),文件内也没有 rm/unlink/TTL。需要注意命中条件:ledger 按 (appId, sessionId, turnId) 哈希分文件,所以同一会话的新 turn 不受影响;但本 PR 自己的崩溃对账设计就是「同一 turn 的新 dispatchAttempt 复用同一 slot」(见 turn-send-ledger 测试 treats dispatch attempts of one logical turn as the same final slot),因此一个残留 inFlightStep 会在 daemon 重启/重试同一 turn 时被跨进程永久命中——这正是门槛 ③ 必须带 inspect/clear(或按 turn 终态删除/纳入 close 清理)的原因,TTL 不能作为唯一兜底(它现在根本不存在)。

关于 ②③ 的交叉点:doc-comment 的指纹不要退化成「正文相等即重放」

doc 分块路径当前的 executeNonIdempotentSequence 已校验 fingerprint + target(doc:<commentId>) + stepCount 三者一致,不一致即拒绝。扩 ② 的 fingerprint(纳入 destination/mentions/附件)时,请保留 doc 路径的 target/stepCount 校验、不要把它简化成纯正文比较;同时确认 doc 轮默认按 final 后,同 turn 第二次 send 被 final 账本拒绝是预期行为(doc 路由语义即「一条评论一个 answer」),但报错仍应是可操作的中文提示而非内部英文。

@deepcoldy

Copy link
Copy Markdown
Owner

第三轮自动评审(fa7f7783,仍以维护者最终审阅为准)。

上轮 4 项合码门槛——逐条复核已收敛

  1. file-only 读取回归:改为有界流式检查(StringDecoder + chunk overlap,多字节边界安全),仅在传了 --expected-link 或确需 turn 指纹时才扫;友好的中文「文件不存在」检查前移到扫描之前。新增 600MiB+1 稀疏文件用例(stub 上传,证明全程不做 UTF-8 整读)和缺失附件中文报错用例。
  2. fingerprint 过窄导致静默 success:identity 扩为 canonical JSON(destination 含 doc 目标/IM 路由/quote/frozen target、addressing、presentation、attention/urgent、images/files/videos 标识;file-only final 用字节 sha256;ledger key 改用 origin session/app)。换目标/换 mention/语音/加附件四类同正文重试实测均 exit 2 + 中文原因 + auxiliary 提示,0 POST。
  3. doc 分块卡死:checkpoint 改为只在 POST 真正发出前落盘;token 获取/参数错误等未触达 provider 的失败以及 4xx/业务码明确拒绝都会清除 in-flight 允许重试;5xx/超时/崩溃等响应未知保留 fail-closed;新增 botmux turn-send-ledger inspect|resolve --outcome delivered|not-delivered --yes 人工恢复入口(含审计日志);completed 记录 30 天 TTL(非阻塞锁、不挡发送);doc 轮无 kind 按 final、显式非 final 给中文可操作报错;prompt 四处同步。
  4. final 后补充发送与提示矛盾:after_success_hint 中英文均改为指引 --response-kind auxiliary;账本拒绝信息全部中文化并给出 auxiliary 出口。

评审方在最新主干(2c95db63f)上本地 rebase(未动你的远端):bun run build 0 错;PR 相关 142 测试 + 周边 send/reply-card/bridge/doc-comment/sandbox/oncall/riff/v3 等 600+ 全绿;PR 新测试在 bun 运行时下也全绿;新逻辑做了 5 组变异(identity 退化为纯正文→4 红、in-flight 不落盘→6 红、4xx 不清 checkpoint→1 红、resolve delivered 不推进→1 红、doc final 闸失效→1 红),均如预期变红后还原。

合最新主干时有一处必须处理的语义冲突(非本 PR 源码缺陷)

主干 09-29 合入的 #1597(2c95db63f)与本 PR 改同一区域。把你的分支 rebase/merge 到最新 master 时,test/cli-send-doc-comment.test.ts 与 test/fixtures/send-doc-comment-capture.ts 会 add/add 冲突,且两边测试在行为上有意互相覆盖:#1597 有 3 个用例的断言是按「doc 轮默认按 progress、auxiliary 可发」的旧语义写的,与本 PR 已确认的门槛③(doc 轮无 kind=final、非 final 硬拒)直接冲突:

  • keeps a turn recoverable after a real default progress send and restart:默认 send 现在就是 final,该轮第二次 send 应与「only target already has a final reply」同形(exit 2、0 请求);
  • keeps two turns ambiguous after a real undefined send(it.each 的 undefined 行):默认 final 会剔除已终结的 A 并把新 send 路由到 B,应是 status 0、两条 final marker,而不是 ambiguous;
  • keeps two turns ambiguous after a real auxiliary send(auxiliary 行):显式 auxiliary 现在在 CLI 即被中文闸拒绝(previous.status=2),旧前提不再成立(fix(lark): 恢复重启后文档评论回复路由 #1597 daemon 侧按 marker 的剔除逻辑仍保留,可由手写 marker 的用例继续覆盖)。

我在本地并集版本里把这 3 个用例按新语义改写期望后跑过,33/33 全绿,即合并后源码行为自洽,需要的只是同步这几个测试断言与 fixture 并集(fixture 需同时支持 CAPTURE_REQUEST+IM 路径和 CAPTURE_DOC_REPLY+一次性拒绝标记)。你下次合入最新 master 时请一并处理,届时我们会基于新 head 重新全量复审。

CI 与小问题

  • 本次 CI 仅 bun-test 一腿红,挂的是 dsh-question-bridge.test.ts > reports spawn, nonzero, timeout, abort and overflow failures(10ms 超时/spawn 时序用例),与本 PR 零文件交集;master 最近一次该用例通过,我在本地 bun 连跑 8 次全绿,判断是时序 flake,重跑该腿即可。
  • P3 follow-up(不阻塞):非 file-only 附件的身份含 resolve(path) 绝对路径,sandbox relay 每次把附件物化到不同 host 私有路径,理论上「沙箱内带附件 final 崩溃后重试」会被大声判为请求不同(提示走 auxiliary 反而可能重复投递);建议附件标识去掉绝对路径(basename+size+mtime,relay 场景)或按字节哈希。
  • 另两个小 nit:legacy 形态的 dispatchStep 若未调 providerRequestStarted 会被乐观记完成(当前只有一个生产调用方,可接受);inspect 列表遇到单个损坏记录会整体失败(fail-closed 可接受)。

@Kerminate

Copy link
Copy Markdown
Contributor Author

合最新主干时有一处必须处理的语义冲突(非本 PR 源码缺陷)

主干 09-29 合入的 #1597(2c95db63f)与本 PR 改同一区域。把你的分支 rebase/merge 到最新 master 时,test/cli-send-doc-comment.test.ts 与 test/fixtures/send-doc-comment-capture.ts 会 add/add 冲突,且两边测试在行为上有意互相覆盖:#1597 有 3 个用例的断言是按「doc 轮默认按 progress、auxiliary 可发」的旧语义写的,与本 PR 已确认的门槛③(doc 轮无 kind=final、非 final 硬拒)直接冲突:

  • keeps a turn recoverable after a real default progress send and restart:默认 send 现在就是 final,该轮第二次 send 应与「only target already has a final reply」同形(exit 2、0 请求);
  • keeps two turns ambiguous after a real undefined send(it.each 的 undefined 行):默认 final 会剔除已终结的 A 并把新 send 路由到 B,应是 status 0、两条 final marker,而不是 ambiguous;
  • keeps two turns ambiguous after a real auxiliary send(auxiliary 行):显式 auxiliary 现在在 CLI 即被中文闸拒绝(previous.status=2),旧前提不再成立(fix(lark): 恢复重启后文档评论回复路由 #1597 daemon 侧按 marker 的剔除逻辑仍保留,可由手写 marker 的用例继续覆盖)。

我在本地并集版本里把这 3 个用例按新语义改写期望后跑过,33/33 全绿,即合并后源码行为自洽,需要的只是同步这几个测试断言与 fixture 并集(fixture 需同时支持 CAPTURE_REQUEST+IM 路径和 CAPTURE_DOC_REPLY+一次性拒绝标记)。你下次合入最新 master 时请一并处理,届时我们会基于新 head 重新全量复审。

我的分支下午已经 merge 过一次远端 master 了(resolve 过冲突),还需要我在当前分支处理这个问题吗

@deepcoldy
deepcoldy merged commit 41cec7e into deepcoldy:master Sep 29, 2026
17 of 18 checks passed
@github-actions

Copy link
Copy Markdown

🚀 Released in v3.33.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants