fix(lark): stop repeating a part the provider sent without a locator - #4897
huangruiteng wants to merge 1 commit into
Conversation
The send path returns sent_unverified in two different situations. When the provider accepted the call and stdout carried a message id, the attempt is recorded and a later retry verifies that locator instead of posting the text again. When the call succeeded but reported no message id, nothing was recorded: manager_reply_parts had no locator, _part_accepted stayed false, and the part loop re-sent the identical text, which is how a reader could get the same part twice. Record the locator-less attempt with its intent digest and provider receipt but an empty message_ref, so the durable record proves a write happened even though it cannot be located, and stop the sequence there instead of sending again: a locator nothing can verify may not be retried blindly, and the part is reported as unverified rather than duplicated. The same guard covers the stall notice, which used the identical recorder-and-verify shape. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
huangruiteng
left a comment
There was a problem hiding this comment.
Request changes conclusion (author-owned PR; GitHub blocks formal self-review)
Reviewed exact head 799ff2ca622f11eab71b8a285695eb15fb7b3b2a.
动机
这个 PR 要解决 Lark provider 已接受发送、却没有返回 message id 时,manager 答复的后续重试重复发文的问题。这个目标是必要的;但当前 head 只挡住了已记录的普通分段,在单条 return 与停滞通知两条真实调用路径仍不能保证“不重发”。
改动思路
inbox_reply.py 在发送成功且无 locator 时写入 manager_return_delivery_attempt_v0,用空 message_ref 表示“已写入但不可读回”;manager_reply_parts.py 看到无 locator 就返回 sent_unverified,停止当前分段重发。这里的持久化权威并不只在 Python:ReturnService.record_attempt 会调用 TypeScript 的 manager.return_delivery.normalize_attempt,而停滞通知的重试依据也是持久化的 PART_STALL_NOTICE_ATTEMPT_KEY。
具体改动
关键代码讲解
_deliver_lark_inbox_outbound:新增无 message id 时记录空 locator 的分支;正常有 id 的记录和读回路径保持不变。attempt_provider_locator/reconciled_part_reply:把无有效 id 的已记录分段判为不可读回,返回未验证结果,不立即再发。reconciled_stall_notice/deliver_stall_notice:同样识别无 locator 的通知,但通知发送器在返回前仍会无条件删除 attempt。- 两个测试文件增加了无 id 的假 provider 和分段用例;它们没有走 TypeScript 规范化的真实 recorder,也没有覆盖通知跨两次持久化重试。
对主干的风险
- [P1] 单条 manager return 会在外部写入后走回可重试状态。
inbox_reply.py新写入的message_ref=""被现有 TypeScriptmanager_return_delivery_attempt_v0明确拒绝(必须非空)。我用无 id 的合成 provider、真实 TypeScript Effect normalizer 从reply_lark_event_inbox跑通后观察到:实际发送一次,随后抛EffectRuntimeRejected: attempt.message_ref must be a non-empty string。未改的ReturnService把这个RuntimeError记为retry_pending,下次会再尝试发送;基线同输入则返回sent_unverified,不会由该异常触发重试。请让 TypeScript 权威契约显式容纳“已接受但无 locator”状态,并让 return pump 将其收敛为不可盲重试的结果;增加经过真实 normalizer/return pump 的回归。 - [P1] 停滞通知的空 locator attempt 被删除,下一轮会重发。
deliver_stall_notice在reconciled_stall_notice返回新增的sent_unverified后仍无条件pop(PART_STALL_NOTICE_ATTEMPT_KEY);外层随后把删除后的状态写盘。我用连续两次deliver_manager_reply_after_length_failure复现:第一次没有新发送却丢失记录,第二次发送了同一通知。请仅在确实准备新发送时清除旧 attempt,并用两次持久化重试测试它。
我的整体评价
方向与 Lark 既有能力边界匹配,代码量也不大,但这个修复必须同时覆盖 canonical TypeScript attempt 与通知重试状态,不能只用 Python 假 recorder 证明去重。精确 head 的两个聚焦测试文件共 84 passed;上述两个真实边界反例仍失败,因此请求修改。未轮询远端 CI(本 Goal 的 review 配置要求本地验证)。后续复审应在同一 head 的完整调用路径证明:无 locator 的单条答复、普通分段、停滞通知都只发生一次 provider write,并能从持久状态读回未验证结论。相关的小型重构应收敛到已有 typed attempt owner,避免另建 Python-only 决策来源。
English verdict: REQUEST_CHANGES - Head 799ff2ca622f11eab71b8a285695eb15fb7b3b2a: the canonical TypeScript attempt rejects an empty locator after a provider write, and the stall-notice retry drops its recorded attempt and resends. Focused tests: 84 passed; both integration counterexamples reproduced.
Goal / gap
A bounded manager answer that the provider rejects for length is split into ordered parts, and each part is counted only once the provider accepted it. That record is what makes a retry resume instead of re-sending. It has a hole:
reply_lark_event_inboxreturnssent_unverifiedin two different situations. When the provider accepted the call and stdout carried a message id, the attempt is recorded (manager_return_delivery_attempt_v0) and a later attempt verifies that locator before sending again. When the call succeeded but stdout carried no message id, nothing was recorded:manager_reply_partshad no locator,_part_acceptedstayed false, and the part loop re-sent the identical text on the next attempt — the reader can receive the same part twice. The row's qualifier named this exactly:verify_lark_inbox_replykeys its readback onmessage_ref, which is the field that is missing, so the fix has to give the no-locator case its own treatment instead of a new retry rule.Change
inbox_reply.py: the no-message-id branch records the attempt with itsintent_digestandprovider_receiptand an emptymessage_ref, so the durable record proves a write happened even though it cannot be located; a recorder failure still fails closed with the existinglark_inbox_reply_delivery_attempt_not_persisted.manager_reply_parts.py:attempt_provider_locatordecides whether a recorded attempt has a message id a readback can key on. A recorded attempt without one is reported as its own typed outcomes (part_locator_unavailable,verification_performed: false, blockerlark_inbox_reply_not_verified) instead of returning to the send path, so the sequence stops where it stopped rather than duplicating the text. The stall notice uses the identical recorder-and-verify shape and gets the same guard.test_a_send_that_reports_no_message_id_records_its_intent(no readback is attempted, the locator-less attempt is recorded) andtest_a_recorded_send_without_a_locator_is_never_sent_again(the sequence does not re-send, keeps the record, and reportssent_unverified).The send-with-a-locator behaviour is unchanged: a recorded locator is still verified and reconciled instead of re-sent.
Validation
uv run --extra test python -m pytest tests/extensions -q→ 990 passed.tests/extensions/test_lark_manager_reply_parts.py tests/extensions/test_lark_inbox_reactions.py tests/extensions/test_lark_markdown_reply.py tests/test_manager_context_roundtrip.py→ 117 passed.ruff checkon the four changed files → clean.Remaining boundary
This makes the unlocatable write a typed, non-repeating outcome; it does not attempt a receipt-keyed readback (the other option the row named) and it cannot prove whether that part actually reached the reader — the sequence reports it as unverified, which is what the caller needs to avoid telling the reader the same thing twice.
Control-plane change: proposed for review, not self-merged.