fix(lark): settle a part sequence from what the provider already accepted - #4844
Conversation
…pted A manager answer too large for one message is delivered as ordered parts, and the durable counter only advances after the provider accepted a part. Two cases then left the delivery reporting an unfinished sequence: - a part the provider verified but whose source reaction cleanup was still pending came back not-ok, so the counter did not advance and a retry posted the same text again; - a sequence whose every part was accepted but whose caller receipt was never written (interrupted settle write, process stop) returned the incomplete signal forever: nothing was re-sent and nothing was ever settled. A part now counts as sent when the provider readback verified it, and the durable record carries the verified completion plus the last accepted part key, so a later attempt settles the delivery from that record instead of re-sending or reporting a false incomplete. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
Focused module cases for the two defects plus the resume/no-duplicate path, and a route-level case that interrupts the caller receipt write and proves the next attempt settles the delivery, re-sends nothing and acknowledges the source. 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: 7ef9388808647421588be5f134a53e99ff49d02d (re-read immediately before publishing).
动机
管家答案超过一条 Lark 消息的上限时,loopx/extensions/lark/manager_reply_parts.py 把它拆成有序分段投递,并把「已投递到第几段」写进原有的持久投递状态。这个计数器只在服务端接受该段之后才前进,于是有两种状态会一直说谎:
- 重复发送:reply 传输的
ok还包含「源消息 reaction 清理完成」这一条件,所以一段已经被 provider 回读确认(sent_verified_cleanup_pending)时会返回 not-ok。循环因此不计数,重试把同一段再发给读者一次。 - 永久 pending:全部分段都已被接受、但调用方自己的回执没写成(结算写入被打断、进程在最后一段之后停止)时,函数返回
None。之后每次重试都走同一条路,什么都不重发,也永远报reply_part_delivery_incomplete,源事件永远不会被 ack。
这正是 todo_1b80f3e82483 里「自愈且幂等」「投递状态必须真实」两条验收(A8/A10),也是该行记录的「同一份简报被重复发送」这类事故。
改动思路
入口是 goal_topic_runtime.py:1231 的长度恢复分支:富文本被拒 → 降级为纯文本 → 仍超限 → 分段。真正决定「哪一段算已投递、这段序列能否结算」的是 manager_reply_parts,所以只改这一层,分段器(outbound.split_lark_outbound_text)、传输(inbox_reply)和状态文件的所有者(manager_reply_delivery.py)都原样复用。
核心取舍是不要发明第二套验证:单条消息路径已经在用传输返回的 external_write_performed / verification_performed / reply_verified 三个事实判断「是否已在频道上」。分段路径改用同一组事实作为计数依据,并把它们连同「序列已完成」写进已有的投递状态,于是:
- 已确认落地的分段不再重发(把 pending 的 reaction 清理留给传输自己去重试,它是独立的一步);
- 记录本身已经证明「全部分段都被接受且已验证」时,从记录结算并返回已验证回执,而不是返回一个谁也完不成的 incomplete;
- 全部分段都在、但记录无法证明已验证时,给这条状态一个自己的类型化原因
reply_part_delivery_completion_unverified,仍然什么都不重发。
没有新增模块、命令、状态文件或授权;新增的两个状态键是可选键,加载器忽略未知键。
具体改动
loopx/extensions/lark/manager_reply_parts.py(+115/-3,生产)、tests/extensions/test_lark_manager_reply_parts.py(+228,测试)、tests/extensions/test_lark_goal_topic_runtime.py(+116,测试)。
关键代码讲解
_part_accepted(manager_reply_parts.py:64):一段算已投递的条件从「ok为真」变成「ok为真,或传输三个验证事实都为真」。sent_unverified仍然不算(可能根本没到频道),而sent_verified_cleanup_pending算——文本已经在频道上,重发只会让读者看到两遍。completed_part_delivery_receipt(:86):只有记录里delivery_parts_complete与delivery_parts_verified都为真、且最后一段的幂等键是sha256:时才返回已验证回执;任一条件不满足就返回None,绝不凭「数量对上了」就宣称送达。deliver_manager_reply_parts(:139):入口先判断记录是否能结算(此时在发送循环之前返回,结构上不可能重发);否则照旧从第一个未发送段继续,并在每段被接受时同时写入验证事实,最后一段额外写入完成标记。
对主干的风险
最强的回归假设是「把没送达的一段算成已送达,于是序列提前结束、答案悄悄残缺」。这由「必须同时满足传输的三个验证事实」挡住,而这三个事实本来就是单条消息路径的结算依据;仅靠计数无法进入完成记录,因为完成标记只在被接受的分段上写入。负路径也有钉子:未验证的失败仍然停在最后一段(delivery_parts_sent == 2),拆分变化的记录仍然从 0 重发,而「全部分段已发送但无法证明已验证」时零发送并给出自己的原因。
scope_fit:生产调用点是 goal_topic_runtime.py:1231 的真实管家路由。change_proportionality:约 90 行净生产代码换掉两种不可自愈的状态;反方最强论点(结算信任记录、不重新回读频道)写在残留风险里,前提是该私有状态只有本模块与路由一个写入者。default_off_isolation 判 not_applicable:没有 opt-in 声明,且未被改动的分段器与传输保证了单条消息路径的逐字兼容。semantic_alignment 判 not_applicable:只在一个私有状态里追加可选键,并给一个此前没有名字的 pending 状态补上类型化原因。authority_semantics:没有新增任何权限或 provider 写入,结算路径在发送循环之前返回。
验证(本地,按仓策略不查远端 CI):ruff check 通过;新模块测试 6 passed;路由级用例 1 passed(8 段全部投递后打断回执写入,下一次尝试结算为 acknowledged、零新增 +messages-reply、收件箱为空);tests/extensions/ 全量 966 passed;基线/头对照脚本在 origin/main e896682c4 与头部分别执行:完整记录基线返回 null、头部返回已验证回执且零发送;cleanup pending 的分段在基线计数为 0(重试会重复),头部计数到 len(parts)。
未执行:真实 provider(路由用例使用仓库自带的 lark-cli 假 runner);reaction 清理本身的重试(属于传输所有)。
我的整体评价
正向且成比例。它把分段投递从「依赖调用方回执」改成「以持久记录为准」,同时消掉一类重复发送和一类永远 pending,且没有扩大授权、没有新增状态文件,单条消息路径可观测语义不变。P2 及以上阻断项:无。两条 P3 保留在正文(结算信任记录、模糊发送仍归传输对账),不阻断合入。
作者是 PR 所有者,GitHub 不允许自我 approve,因此以 COMMENTED review 记录同一结论。
English verdict: APPROVE - the part-sequence path now counts a part when the provider verified it and settles from the durable record, removing both the duplicate re-send and the permanent pending state; single-message delivery, the splitter and the transport are unchanged, and no blocking finding was found at head 7ef9388.
Problem
An over-limit steward answer is delivered as ordered parts, and
loopx/extensions/lark/manager_reply_parts.pyadvances the durable counter only after the provider accepted a part. Two states were left reporting an unfinished sequence:okfrom the reply transport also requires the source reaction cleanup to have finished, so a part the provider already read back (sent_verified_cleanup_pending) came back not-ok. The loop then refused to count it, and the retry posted the same text to the reader again.Nonewhen the caller never wrote its own receipt (interrupted settle write, process stop between the last part and the receipt). Every retry re-entered the same path, re-sent nothing and reportedreply_part_delivery_incompleteforever; the source event was never acknowledged.Both are the row
todo_1b80f3e82483acceptance items about a self-healing, idempotent steward channel and truthful delivery states.Change
reply_part_delivery_completion_unverified) rather than the generic incomplete one, and still never re-sends.Validation
ruff checkclean.tests/extensions/test_lark_manager_reply_parts.py: 6 cases (partial resume starts at the first unsent part, verified-cleanup-pending part is not re-sent, complete record settles with zero sends, complete-but-unverified record reports its own reason, a changed split restarts, an unfinished record never claims verification).tests/extensions/test_lark_goal_topic_runtime.py: an over-limit answer delivers all 8 parts, the caller receipt write is interrupted, and the next attempt settles the delivery toacknowledgedwith zero additional sends and an empty inbox.origin/maine896682c4: the complete record returnsnullon base and the verified receipt on head; a cleanup-pending part leaves the counter at 0 on base (so the retry duplicates) and atlen(parts)on head.Risk
Runtime behavior change limited to the part-sequence path (
loopx/extensions/lark/). Single-message delivery, the durable state schema, and the resume rules for a changed split are unchanged. A legacy state that shows all parts sent without the new completion facts keeps the old behavior and now names its own pending reason. The change cannot make a delivery start sending again: a complete record returns before the send loop.