fix(lark): reconcile an ambiguous part send instead of repeating it - #4861
Conversation
A part the provider accepted but did not read back left nothing behind: the counter stayed at that part and the next attempt sent the reader the same text again. The transport already returns a provider locator for every accepted send, and the module now keeps that locator in the same durable delivery state. The next attempt verifies the recorded locator with the existing read-back verifier before sending anything, so a part the provider still reports is counted without a second write; a locator the provider cannot confirm falls back to sending the part, because losing the reader text is worse than a duplicate. A part confirmed this way counts as verified for the completion record, so a sequence that ended in a reconciled part can still settle from its record. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
Focused cases: an unverified send records its provider locator, a recorded locator is confirmed instead of re-sent, an unconfirmed locator still sends the part, and a confirmed locator counts toward the completion record. The route-level case drives a real part sequence whose first readback fails and proves the retry confirms that part, so the reader receives it exactly once and the delivery settles. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
huangruiteng
left a comment
There was a problem hiding this comment.
Approval conclusion (author-owned PR; GitHub blocks formal self-approval)
Exact head: b1dc46ab891f9b03b09292044ea94451450376eb (re-read immediately before publishing).
动机
管家长答复会拆成有序分段投递,只有 provider 回读确认后该段才计数。可上一轮还漏了一种状态:provider 已经接受了这一段,但回读没能确认(sent_unverified:发送返回了 message id,+messages-mget 却没确认文本)。此时计数器停在这一段,下一次尝试把同样的文字再发给读者一遍——正是这条 row(todo_1b80f3e82483,验收锚 A8)记录过的重复发送类事故。
关键事实是:transport 早就把这份证据交出来了。reply_lark_event_inbox 在 provider 返回 message id 后会调用 delivery_attempt_recorder,给出 {schema_version, provider, message_ref, intent_digest, provider_receipt};verify_lark_inbox_reply 能在不发送的前提下把它读回来。分段路径此前既没记录、也没使用过这两个能力。
改动思路
入口仍是 goal_topic_runtime 的长度恢复分支 → deliver_manager_reply_after_length_failure → deliver_manager_reply_parts,所以只改这一层:分段循环把 transport 给的 locator 记进同一份持久投递状态(delivery_part_attempt = {index, attempt}),下次尝试在发送之前先用既有 verifier 确认这条 locator。确认了就计数、不写第二次;确认不了就照旧发送——对读者来说,多一段文字比少一段文字要好。
复用而不是另造:delivery_attempt_recorder(记录)、verify_lark_inbox_reply(回读校验)都来自既有 transport;manager_returns / roundtrip 的 return 路径已经在用同一套。区别只在消费方:那边由 TS 状态机消费记录(所以经 typed owner 归一化),这里由 Python verifier 消费,因此直接存 verifier 自己校验的形状,并把这个判断写进了评审。
具体改动
loopx/extensions/lark/manager_reply_parts.py(+88/-10)、tests/extensions/test_lark_manager_reply_parts.py(+136)、tests/extensions/test_lark_goal_topic_runtime.py(+112)。合计 +340/-10。
关键代码讲解
reconciled_part_reply(manager_reply_parts.py:161):取出 resume 索引对应的 locator,调用verify_lark_inbox_reply(只做 dry-run 预览 +messages-mget回读,不写)。只有reply_verified is True才返回结果;没有 locator 或确认不了就返回None,调用方随即走原来的发送路径。deliver_manager_reply_parts(:195):循环先尝试 reconcile;未确认时清掉旧 locator、用delivery_attempt_recorder记录本次发送返回的新 locator;该段被接受后立刻清掉 locator,状态里不会再留着已解决的尝试。_part_verified(:59):判定从「本段自己写过且回读通过」放宽为「provider 回读确认过这一段」。这既覆盖发送后的回读,也覆盖 reconcile 出来的确认,因此被 reconcile 的最后一段同样能进入完成记录、后续可从记录结算(上一刀的 settle-from-record 逻辑不会被这次改动重新打开缺口)。
对主干的风险
最强回归假设是「状态里一条陈旧或伪造的 locator 被当作送达证据,于是漏发一段」。这由 verifier 自身的约束挡住:记录里的 intent_digest 与 provider_receipt 必须与该段文本重新构建的预览一致,且 messages-mget 回读的文本必须匹配;任何不满足都返回未确认,随后照常发送。反向风险(确认不了却仍然发送)是刻意选择并且有钉子:test_an_unconfirmed_locator_still_sends_the_part。
scope_fit:生产调用点是 goal_topic_runtime 的长度恢复分支,路由级用例走到了那里。change_proportionality:约 78 行净生产代码换掉一类重复发送,未新增 transport/verifier/状态文件/命令;反方最强论点(reconcile 会给重试增加 provider 回读调用)写在残留风险里。default_off_isolation 判 not_applicable:没有 opt-in 声明,状态里没有该键时行为逐字不变。authority_semantics:只回读同一个 bot 已写过的消息,不产生新写入、不新增权限。semantic_alignment 判 not_applicable:复用既有 attempt 形状与 verifier,只加一个私有可选键。
验证(本地,按仓策略不查远端 CI):ruff 通过;模块用例 10 passed;路由级用例 1 passed(真实分段流程里首段回读失败 → 首次 pending 且记下 locator → 重试确认该段,读者只收到一次,其余分段继续发送并结算为 acknowledged);tests/extensions/ 全量 973 passed(190.76s)。基线/头对照:同一份「已记录但未确认」的状态,base 59e11118f 把第 0 段又发了一次(本轮 8 次发送),head 确认该段、本轮 7 次发送、第 0 段 0 次。
未执行:真实 provider(模块与路由测试都使用假 lark-cli runner);locator 指向的消息后来被编辑的情况(verifier 会判定不一致并回退发送)。
我的整体评价
正向且成比例:它把「模糊发送」从盲目重发改成先按 provider 证据确认,同时在无法确认时仍保证读者拿到文本;改动只在一个模块内部,不新增授权、状态文件或协议字段,并让「确认过的分段」与「自己写过的分段」在完成记录里口径一致。P2 及以上阻断项:无。两条 P3 保留在正文——没有 message id 的模糊发送仍无 locator 可对账;读回不一致时会重发(安全方向,但仍是重复)。
作者是 PR 所有者,GitHub 不允许自我 approve,因此以 COMMENTED review 记录同一结论。
English verdict: APPROVE - an ambiguous part send is now confirmed through the transport own read-back verifier before the retry repeats it, with a send fallback when the provider cannot confirm it; the base/head probe shows the duplicate on base and none at head b1dc46a, no blocking finding was found, and the remaining no-locator ambiguity is recorded as a P3 follow-up.
Problem
A steward answer too large for one message is delivered as ordered parts, and a part counts only when the provider readback confirms it. When the provider accepted a part but the readback could not confirm it (
sent_unverified: the send returned a message id, the+messages-mgetcheck did not confirm the text), the counter stayed on that part and the next attempt sent the reader the same text again. That is the duplicate-send class this row (todo_1b80f3e82483, acceptance anchor A8) was opened for.The transport already hands the module a provider locator for every accepted send:
reply_lark_event_inboxcallsdelivery_attempt_recorderwith{schema_version, provider, message_ref, intent_digest, provider_receipt}after the provider returns a message id, andverify_lark_inbox_replycan read that locator back without sending anything. The part-sequence path used neither.Change
delivery_attempt_recorderand keeps the returned locator in the same durable delivery state (delivery_part_attempt = {index, attempt}).verify_lark_inbox_reply. If the provider still reports that part, it is counted without a second write; if the provider cannot confirm it, the part is sent (as before), because dropping the reader text is worse than a duplicate._part_verifiednow means "the provider reported this part present" (readback performed + verified) rather than requiring a write of its own, which is what makes a reconciled part count for the completion record too.Validation
tests/extensions/test_lark_manager_reply_parts.py(10 cases): an unverified send records its locator; a recorded locator is confirmed instead of re-sent; an unconfirmed locator still sends the part; a confirmed locator counts toward the completion record.tests/extensions/test_lark_goal_topic_runtime.py: a real over-limit answer whose first part readback fails. First attempt endsreply_delivery_pendingwith the locator recorded and one part sent; the retry confirms that part, the reader receives it exactly once, the remaining parts are sent, and the delivery settlesacknowledged.Risk
Behaviour is confined to the manager part-sequence path. It can only remove sends (when the provider confirms the recorded locator); when the locator cannot be confirmed, the previous send path runs unchanged. Verification is a read-back only: it performs no write and needs no new authority. Residual: the ambiguous send that returned no message id has no locator to check, so that part is still re-sent.