Skip to content

fix(lark): reconcile an ambiguous part send instead of repeating it - #4861

Merged
huangruiteng merged 2 commits into
mainfrom
codex/steward-part-reconcile-0921
Sep 21, 2026
Merged

huangruiteng merged 2 commits into
mainfrom
codex/steward-part-reconcile-0921

Conversation

@huangruiteng

Copy link
Copy Markdown
Collaborator

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-mget check 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_inbox calls delivery_attempt_recorder with {schema_version, provider, message_ref, intent_digest, provider_receipt} after the provider returns a message id, and verify_lark_inbox_reply can read that locator back without sending anything. The part-sequence path used neither.

Change

  • The parts loop passes a delivery_attempt_recorder and keeps the returned locator in the same durable delivery state (delivery_part_attempt = {index, attempt}).
  • Before sending the part at the resume index, the loop verifies a recorded locator with the existing 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.
  • The locator is cleared as soon as the part is accepted, so the record never points at a resolved attempt.
  • _part_verified now 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

  • ruff clean.
  • 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.
  • Route-level case in tests/extensions/test_lark_goal_topic_runtime.py: a real over-limit answer whose first part readback fails. First attempt ends reply_delivery_pending with 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 settles acknowledged.
  • Full extension suite: 973 passed.

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.

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 huangruiteng left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_failuredeliver_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_replymanager_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_digestprovider_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_isolationnot_applicable:没有 opt-in 声明,状态里没有该键时行为逐字不变。authority_semantics:只回读同一个 bot 已写过的消息,不产生新写入、不新增权限。semantic_alignmentnot_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.

@huangruiteng
huangruiteng merged commit 3de0236 into main Sep 21, 2026
21 of 25 checks passed
@huangruiteng
huangruiteng deleted the codex/steward-part-reconcile-0921 branch September 21, 2026 11:19
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.

1 participant