Skip to content

fix(lark): stop repeating a part the provider sent without a locator - #4897

Open
huangruiteng wants to merge 1 commit into
mainfrom
codex/steward-no-locator-part-0922
Open

huangruiteng wants to merge 1 commit into
mainfrom
codex/steward-no-locator-part-0922

Conversation

@huangruiteng

Copy link
Copy Markdown
Collaborator

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_inbox returns sent_unverified in 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_parts had no locator, _part_accepted stayed 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_reply keys its readback on message_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 its intent_digest and provider_receipt and an empty message_ref, so the durable record proves a write happened even though it cannot be located; a recorder failure still fails closed with the existing lark_inbox_reply_delivery_attempt_not_persisted.
  • manager_reply_parts.py: attempt_provider_locator decides 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, blocker lark_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.
  • Tests: test_a_send_that_reports_no_message_id_records_its_intent (no readback is attempted, the locator-less attempt is recorded) and test_a_recorded_send_without_a_locator_is_never_sent_again (the sequence does not re-send, keeps the record, and reports sent_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 -q990 passed.
  • Focused: 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.py117 passed.
  • ruff check on 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.

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 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.

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,也没有覆盖通知跨两次持久化重试。

对主干的风险

  1. [P1] 单条 manager return 会在外部写入后走回可重试状态。 inbox_reply.py 新写入的 message_ref="" 被现有 TypeScript manager_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 的回归。
  2. [P1] 停滞通知的空 locator attempt 被删除,下一轮会重发。 deliver_stall_noticereconciled_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.

This branch has not been deployed

No deployments
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