Skip to content

fix(lark): tell the reader when a manager answer stalls mid-sequence - #4869

Merged
huangruiteng merged 3 commits into
mainfrom
codex/steward-stall-notice-0921
Sep 21, 2026
Merged

huangruiteng merged 3 commits into
mainfrom
codex/steward-stall-notice-0921

Conversation

@huangruiteng

@huangruiteng huangruiteng commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

动机

超长管家答复会分段投递,而「完整答复保存在 LoopX 管家会话中」这句话只挂在最后一段上。当 provider 持续拒绝下一段时,读者手里只剩开头几段裸文本,没有任何东西说明这条答复被截断了——分段的进展被记录下来,但读者看到的信息与记录不一致。

改动思路

给分段投递记录加一个「连续卡住次数」的概念,只有在这条分段序列已经失败不止两次之后,才向读者补发一次有界通知,说明已经发出多少段、剩余内容在哪里、还会继续重试。计数每次失败都落盘(重试会从磁盘重载记录),并在记录已经不再描述当前切分时丢弃。

通知本身沿用这条链条已有的「先记录 provider 定位、读回确认、再决定是否重发」的幂等约定:provider 已经收下但读回没确认的通知,下一次只做 verify 而不再发第二遍,避免读者被同一件事反复打扰。

具体改动

  • loopx/extensions/lark/manager_reply_parts.py
    • 新增 PART_STALL_NOTICE_KEY / PART_STALL_COUNT_KEY / PART_STALL_NOTICE_ATTEMPT_KEY / PART_STALL_NOTICE_MIN_STALLS / MANAGER_REPLY_STALL_NOTICE
    • 新增纯函数 plan_stalled_part_notice(delivery_state):已发过通知、卡住次数不足 3、sent/count 不是合法整数、或 0 < sent < count 不成立时都返回 None
    • 新增 deliver_stall_notice,先 reconciled_stall_notice 校验已记录的定位(verify_lark_inbox_reply),未确认才发送,并把 provider 定位写进 delivery_part_stall_notice_attempt
    • deliver_manager_reply_after_length_failure:分段序列未完成时累加计数;达到阈值则发一次通知,成功(或读回确认)才把 PART_STALL_NOTICE_KEY 置真,并记录 last_delivery_notice_status;无论是否发通知都把计数写回磁盘。
    • deliver_manager_reply_parts:切分与记录不符(sent = 0 重启)时丢弃卡住计数,避免上一次切分的失败历史替这一次开口。
    • manager_part_delivery_pending_result 增加 delivery_notice_sent 读回字段。
    • _part_verified_reply_verified_part_accepted_reply_on_channel:分段与通知共用同一条「这段文字可能已经在通道上」的判定。

对主干的风险

改动只落在已存在的「分段投递失败」路径上,不触碰单条消息投递、成功路径或任何其他 Lark 路由;新增的键和字段都只在分段未完成的记录里出现,老记录缺键时按「从未发过通知、计数为 0」处理,保持原有行为。新增的发送是文本格式的一次性通知,读回失败时只会重试通知本身,不会重复分段;已被 provider 收下但没读回的通知会在下一次被 verify 确认,而不是重复投递。前两次失败保持静默,因此临时的 provider 抖动不会变成多余消息。

验证

  • pytest -q tests/extensions tests/test_manager_team_plan_guidance.py tests/test_manager_channel_binding.py tests/test_chat_lark_api_contract.py tests/capabilities/test_outbound_guidance.py → 1093 passed。
  • ruff check tests loopx/canary loopx/control_plane loopx/domain_packs loopx/presentationmypycanary premerge --from-git-diff --git-diff-base origin/main(17 条命令,0 failure,0 manual hold)通过。
  • examples/lark-extension-activation-smoke.pyexamples/control_plane/cli-output-budget-regression-smoke.pyexamples/semantic-vocabulary-drift-smoke.py 通过。
  • 基线与改动的对照探针(同一份磁盘记录跑 4 次同样的分段重试):基线 origin/main 3de02368a 4 次尝试全部静默,读者只拿到 2/8 段裸文本;改动后第 1、2 次静默,第 3 次发出且仅发出 1 条通知,第 4 次不重复。
  • 幂等对照:把通知的发送结果改成「provider 收下但读回未确认」后,去掉 reconcile 会把通知发两遍(新回归失败),保留 reconcile 则第二次只做 verify,读者只看到一条。
  • 路由级测试 test_a_stalled_part_sequence_tells_the_reader_what_was_delivered 走完整 process_lark_goal_topic_event 路径,并回读落盘的 delivery.json 确认 delivery_part_stall_notice 为真、status 仍为 pending

rebase 说明

已 rebase 到最新 main76bd61ddd)。冲突只在 tests/extensions/test_lark_goal_topic_runtime.py:main 新增了 test_a_long_manager_answer_is_delivered_as_one_message(普通长答复保持单条消息),本 PR 新增的是分段卡住时的读者通知,两者独立,均已保留。main 上 manager_reply_parts.py 的改动(分段发送 short_message_limit=None)与本改动无重叠。

关键代码讲解

delivery_state[PART_STALL_COUNT_KEY] = int(delivery_state.get(PART_STALL_COUNT_KEY) or 0) + 1
spoken = deliver_stall_notice(...)   # 先 reconcile,再发送并记录 provider 定位
if spoken is not None:
    if _reply_on_channel(spoken):
        delivery_state[PART_STALL_NOTICE_KEY] = True
    delivery_state["last_delivery_notice_status"] = str(spoken.get("status") or "reply_failed")

计数在判断通知之前累加、并在判断之后无条件落盘:重试每次都从磁盘重载记录,如果只在发通知的那次写入,计数会永远停在 1,通知不可能触发。通知走既有的 reply_lark_event_inbox,因此读回、幂等键与失败语义和分段投递完全一致;provider 定位由 delivery_attempt_recorder 在发送成功后、读回之前落盘,所以「收下但没读回」的通知下一次会被 reconciled_stall_notice 确认而不是重发。

@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: 8c5d7eea014c268e9c71a479036c9a4fd4993d39 (re-read immediately before publishing).

动机

管家行 todo_1b80f3e82483 要求管家通道投递自愈且幂等,并且投递状态必须诚实。这条链前面的切片已经交付了「超长答复按段投递」(A9)和「provider 已接受但读回失败的分段不再重发」(#4861)。这条 PR 收口最后一个读者可见的缺口:分段序列卡住时,读者什么都不知道

「完整答复保存在 LoopX 管家会话中」这句话只挂在最后一段上。当 provider 持续拒绝下一段时,读者手里只剩开头几段裸文本,没有任何东西说明这条答复被截断;投递记录说序列未完成,通道上却看不出这个信号。读者可能据此把片段当成完整答复去做事,而后续重试无法追溯地修正这个理解。

作者主张:同一切分下连续失败超过两次后,补发一次有界通知,说明已发出多少段、剩余内容在哪里。这是真实缺口上的真实增量,而这条行的剩余工作(维护者合并 + 出货读回)不在本 PR 范围内。

改动思路

入口是 process_lark_goal_topic_event 的长度恢复分支(goal_topic_runtime.py:1231):单条消息因长度被拒后,它把已校验的正文降级为纯文本并交给 deliver_manager_reply_after_length_failure 分段投递。权威状态是 manager_reply_delivery.py 写出的私有投递记录(delivery_parts_sent / delivery_part_count / delivery_part_attempt)。决策边界是新加的纯函数 plan_stalled_part_notice,正向路径是「计数 → 判断 → 发送 → 读回标记」,失败重试由既有的 inbox 事件重试承担。

复用了既有的四样东西:reply_lark_event_inbox 传输、write_delivery 记录写入、既有的 part_delivery_incomplete_reason 返回、以及既有的完成/结算路径。因此这不是第二条投递路径,而是在既有失败路径上多了一个「要不要说话」的判断。

对比既有实现:MANAGER_REPLY_OVERFLOW_NOTE 负责解释切分,但只在整条答复都上了通道之后才成立;part_delivery_incomplete_reason 是给机器看的,永远到不了读者。两者都无法表达「卡住了」。

具体改动

生产代码只动 loopx/extensions/lark/manager_reply_parts.py(+72/-2),测试 +246 行分两个文件。

  • 新增 PART_STALL_NOTICE_KEY = delivery_part_stall_noticePART_STALL_COUNT_KEY = delivery_part_stall_count、阈值 PART_STALL_NOTICE_MIN_STALLS = 3 与消息常量 MANAGER_REPLY_STALL_NOTICE
  • 新增纯函数 plan_stalled_part_notice(delivery_state):已发过通知、卡住次数不足 3、计数不是合法整数(显式排除 bool)、或 0 < sent < count 不成立时返回 None
  • deliver_manager_reply_after_length_failure:分段序列未完成时累加计数;达到阈值则经同一传输发一次通知,读回确认后才把 delivery_part_stall_notice 置真并记录 last_delivery_notice_status;无论是否发通知都把计数写回磁盘。
  • deliver_manager_reply_parts:切分与记录不符(sent = 0 重启)时丢弃卡住计数。
  • manager_part_delivery_pending_result 增加 delivery_notice_sent 读回字段。

关键代码讲解

1. plan_stalled_part_notice(manager_reply_parts.py:205)——把所有「不该说话」的情况收敛成一个纯判断

它只读记录、不发消息,因此可以用现有的 _stalled_state 夹具直接覆盖每一条边界:sent = 0(什么都没发出去,此时调用方已经在报单条消息失败)返回 Nonesent == count(其实都发出去了)返回 None;计数是 bool 或非整数返回 None。任何无法识别的记录都保持安静,而不是发出可能错误的消息。

2. deliver_manager_reply_after_length_failure(manager_reply_parts.py:341)——计数必须先落盘,否则通知永远到不了

delivery_state[PART_STALL_COUNT_KEY] = int(delivery_state.get(PART_STALL_COUNT_KEY) or 0) + 1
notice = plan_stalled_part_notice(delivery_state)
if notice is not None:
    spoken = reply_lark_event_inbox(..., text=notice, execute=True, runner=reply_runner)
    if spoken.get("ok") is True or spoken.get("reply_verified") is True:
        delivery_state[PART_STALL_NOTICE_KEY] = True
    delivery_state["last_delivery_notice_status"] = str(spoken.get("status") or "reply_failed")
delivery_state["updated_at"] = datetime.now(timezone.utc).isoformat()
write_delivery(delivery_path, delivery_state)

关键在最后两行无条件写回。开发过程中这里最初只在发通知的分支里写盘,结果路由测试直接失败(delivery_notice_sent 拿到 False):重试每次都从磁盘重载记录,计数永远停在 0 → 1,通知不可能触发。这正是「记录派生 vs 内存假设」的典型坑,现在由 test_a_stalled_part_sequence_tells_the_reader_what_was_delivered 钉住。

3. deliver_manager_reply_parts(manager_reply_parts.py:234)——卡住计数不能跨切分存活

记录里的切分与当前不符时,函数会把 sent 归零重发。此时同步 pop 掉卡住计数,因为「连续卡住次数」描述的是这一次切分的重试历史。否则上一个切分攒下的失败记录会替这一次开口。test_a_changed_split_restarts_instead_of_resuming_mid_answer 现在带上了一个陈旧的 3 并断言重启后它消失了。

对主干的风险

改动完全落在既有的「分段投递已失败」路径上,不触碰单条消息投递、成功路径、切分器、8 段上限、最后一段的溢出提示、#4861 的歧义发送对账,也不触碰任何调度或配额。

最可能的回归是通知没落盘导致永远静默(就是上面第 2 点那个缺陷,已在开发中真实复现并修复);其次是通知本身被拒却记成已送达,由「只在 ok/reply_verified 为真时才置位」防止,test_a_rejected_notice_is_offered_again_on_the_next_attempt 断言此时标志保持 False、并记录 last_delivery_notice_status。第三是陈旧计数提前触发,由第 3 点的 pop 与断言覆盖。

可观测性是记录里的 delivery_part_stall_count / delivery_part_stall_notice / last_delivery_notice_status,加上路由结果里的 delivery_notice_sent。回滚只需 revert:老记录没有新键时按「从未发过通知、计数为 0」处理,无需迁移。

远端 CI 状态(如实记录):这个 head 上 25 项检查有 5 项红:merge-gatepytest(分片聚合器)、node-minimum-compatibilitytest-shard (2)test-shard (4)。这些都是既有红,与本 diff 无关:其中 merge-gate / node-minimum-compatibility / pytest / test-shard (4) 在与本 PR 同基线的上一个 PR(#4861,run 35588278533)里就已经红;test-shard (2) 的红只来自 tests/cli_commands/test_project_lifecycle_goal_channel.py::test_refresh_state_dispatches_and_replays_post_writeback_sidecarsettlement.py:168AttributeError: 'types.SimpleNamespace' object has no attribute 'progress'),我在未改动的 origin/main 3de02368a 独立 worktree 上复现了完全相同的失败;test-shard (4) 的红只来自 tests/test_turn_machine_credential.py,同样在未改动的基线上复现。本 PR 只动一个 Lark 扩展模块和两个测试文件,不涉及 control_plane/quota、CLI lifecycle 与 turn 凭据。按 packet 的 wait_for_ci=false,远端 CI 不构成本次 review 的证据缺口,但因为它影响合并就绪度,这里如实披露。

未验证的维度:通知文本没有在真实飞书租户上跑过;它走的是本 head 上分段投递已经在用的同一条传输契约。合并就绪度当前为 ready=falsemerge_state: BEHIND、缺少该 head 的有效评审结论、上述既有红),因此本 PR 保留给维护者决定,不由作者合并。

语义与 CI 对齐

semantic_alignment 判为 not_applicable:改动落在 Lark 扩展模块自己的私有投递记录(两个 additive 键)与读者可见文本上,不涉及控制面状态、权限边界、配额/调度、持久化状态契约或公开 CLI/API 契约,因此不需要 candidate_decision 或整份 RFC 阅读。

我的整体评价

无阻断性发现。

同一条磁盘记录跑四次同样的失败重试,基线 origin/main 3de02368a 与 head 的对照是干净的:基线四次全静默、读者只有 2/8 段的裸文本、通知数 0;head 第 1、2 次静默,第 3 次发出且仅发出 1 条「本条答复超过可发送长度,目前只发出了前面的 2/8 段;完整答复保存在 LoopX 管家会话中,剩余分段会继续重试。」,第 4 次不重复。

验证:tests/extensions/ 全套 978 passed;模块级 14 passed;路由级 -k stalled_part_sequence 1 passed(走完整 process_lark_goal_topic_event,并回读落盘的 delivery.json 确认标志为真且 status 仍为 pending);ruff 全绿。

体量比例合适:约 72 行生产代码换来「读者不再把残缺答案当成完整答案」,且安静是默认、说话需要真实重试。剩余风险是通知文本缺少真实租户验证,以及该标志尚未投影进类型化 return/receipt 路径——等真有 TS 消费方时再补投影即可,不构成本 PR 的阻断。

作者是 PR 所有者,GitHub 不允许自我 approve,因此以 COMMENTED review 记录同一结论:无阻断性发现,证据充分,可以合并。这是控制面改动,仍保留给维护者决定,作者不自行合并。

English verdict: APPROVE - 4869@8c5d7eea014c268e9c71a479036c9a4fd4993d39 - bounded, evidence-backed fix for a real steward UX gap (a stalled over-limit answer left the reader unlabelled fragments); isolated to the existing part-delivery failure path, with a base/head counterfactual (base 0 notices, head exactly one after three stalls) and 978 passing extension tests. All five red remote checks on this head are pre-existing: four were already red on the same baseline in #4861's run, and the two failing shard tests reproduce identically on untouched origin/main 3de0236.

@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: 891ebe28c8a267f9bd4154cbe02c541c59f29e59(发布前刚重新读取远端 head)。此前的 approval 记录绑定在旧 head 8c5d7eea0,本次 rebase 与 refine 后已按流程重发。

动机

管家行要求管家通道的投递自愈、幂等且状态诚实。这条链前面的切片已经交付了「超长答复按段投递」,以及「provider 已接受但读回失败的分段不再重发」。这条 PR 收口最后一个读者可见的缺口:分段序列卡住时,读者什么都不知道

「完整答复保存在 LoopX 管家会话中」这句话只挂在最后一段上。当 provider 持续拒绝下一段时,读者手里只剩开头几段裸文本,没有任何东西说明这条答复被截断;投递记录写着 pending,通道上却看不出这个信号。作者主张:同一切分连续失败超过两次后,补发一次有界通知,说明已发出多少段、剩余内容在哪里。这是真实缺口上的真实增量。

改动思路

入口是 process_lark_goal_topic_event 的长度恢复分支:单条消息因长度被拒后,它把已校验正文降级为纯文本并交给 deliver_manager_reply_after_length_failure。权威状态是 manager_reply_delivery 写出的那份私有投递记录。决策边界是新纯函数 plan_stalled_part_notice,发送复用既有的 reply_lark_event_inbox,重试由既有的 inbox 事件重试承担。

本轮 refine 把这条 PR 自己承诺的「只发一次」补完整:通知复用分段那套「先落盘 provider 定位、下一次先 verify 再决定是否重发」的既有约定(reconciled_stall_notice + deliver_stall_notice)。原因是可复现的:transport 在「provider 收下、读回未确认」时会把定位写进记录,而旧实现不读这份定位,于是后续每次卡住都会再发一条同样的通知——正是这条 PR 要避免的事。同一条判定(_reply_on_channel)现在被分段和通知共用,而不是各写一份。

具体改动

exact head 相对 origin/main 改 3 个文件、+542/-20:生产代码只落在 loopx/extensions/lark/manager_reply_parts.py(+205/-20),测试 +337(test_lark_manager_reply_parts.pytest_lark_goal_topic_runtime.py)。

新增的持久事实只有三个可选键(delivery_part_stall_countdelivery_part_stall_noticedelivery_part_stall_notice_attempt)与 pending 结果上的 delivery_notice_sent;没有 schema 版本变化、没有新文件、没有新依赖。计数在判断之前累加、判断之后无条件落盘:重试每次都从磁盘重载记录,只在发通知那次写入会让计数永远停在 1,通知不可能触发。切分与记录不符(sent = 0 重启)时丢弃计数,避免上一次切分的失败历史替这一次开口。

关键代码讲解

  1. plan_stalled_part_notice:已发过通知、卡住次数不足 3、sent/count 不是合法整数(含排除 bool)、或 0 < sent < count 不成立时都返回 None。因此「一段都没发出去」和「其实已经全发出去但完成态未验证」这两种情况都保持安静——前者读者没有可误解的片段,后者读者已经拿到全文。
  2. deliver_stall_notice:先 verify 已记录的定位,未确认才发送;发送时用 delivery_attempt_recorder 在「provider 收下之后、读回之前」把定位落盘,所以「收下但没读回」的通知下一次会被确认,而不是再发一遍。发送前清掉旧定位,保证记录始终指向最近一次未确认的通知。
  3. deliver_manager_reply_after_length_failure:计数 → 通知 → 记录结果 → 落盘 → 仍然返回同一个 incomplete reason(机器语义不变,status 仍是 pending)。是否算「读者已被告知」由 _reply_on_channelok 或读回确认)判定。
  4. manager_part_delivery_pending_result:新增 delivery_notice_sent,取自落盘事实而非本次尝试,老记录缺键时为 false,形状向后兼容。
  5. 测试:决策表(1/2 次静默、3 次开口、没发出去/已全发保持静默)、路由级 test_a_stalled_part_sequence_tells_the_reader_what_was_delivered(走完整 process_lark_goal_topic_event 并回读 delivery.json)、被拒通知会重试、以及本轮新增的 test_a_notice_the_provider_took_but_did_not_read_back_is_confirmed

rebase 与 refine 说明

已 rebase 到最新 main76bd61ddd)。冲突只在 tests/extensions/test_lark_goal_topic_runtime.py:main 新增的 test_a_long_manager_answer_is_delivered_as_one_message(普通长答复保持单条)与本 PR 的分段卡住通知测试落在同一位置,两者独立、均已保留;main 在同一个生产文件里的 short_message_limit=None 改动无冲突且语义互补。

验证

  • pytest -q tests/extensions tests/test_manager_team_plan_guidance.py tests/test_manager_channel_binding.py tests/test_chat_lark_api_contract.py tests/capabilities/test_outbound_guidance.py1093 passed
  • ruff check tests loopx/canary loopx/control_plane loopx/domain_packs loopx/presentationmypycanary premerge --from-git-diff --git-diff-base origin/main(17 条命令,0 failure,0 manual hold)通过。
  • examples/lark-extension-activation-smoke.pyexamples/control_plane/cli-output-budget-regression-smoke.pyexamples/semantic-vocabulary-drift-smoke.py 通过。
  • 基线与改动对照:基线 4 次重试全部静默,读者只拿到 2/8 段;改动后第 1、2 次静默,第 3 次发出且仅一条,第 4 次不重复。
  • mutation check:撤掉本轮新增的 reconciled_stall_notice 调用后,新回归会以「通知发了两遍」失败;恢复后通过——这证明该回归真正盯着「只发一次」这条不变量,而不是只在好路径上通过。

对主干的风险

读者打扰面有界:前两次卡住保持静默(临时抖动不会变成多余消息),一段未发出时保持静默,通知每切分最多一条;通知文本是固定模板,明确写出 已发出 X/Y 段,因此即使序列仍在缓慢推进,读者得到的也是「进度 + 出处」而不是误导。

兼容性:新增的都是可选键与新增字段,老记录按「从未通知、计数为 0」处理;分段投递本身、单条消息路径、其他 Lark 路由、权限与 authority 均未改动。回滚只需 revert,旧代码仍能读取新记录(多余键被忽略)。

残余风险(已记录):路由级测试用 stub runner 驱动真实事件入口与真实投递记录,没有打真实 lark-cli,所以「收下但读回未确认」的确认路径是按 transport 的既有结果语义验证的;计数统计的是「未能完成的尝试」而不是「连续零进展」,因此缓慢推进的答复可能在第三次卡住时被通知(通知文本会写明已发段数);远端 CI 在本轮审阅时仍在跑,main 上既有的两条 shard 失败与 node-minimum-compatibility 的 SQLite 资格问题会独立让 merge-gate 变红,与本 diff 无关。

我的整体评价

这是一个小而完整的收口:读者不再被迫把残缺答复当成完整答复,通知有界、只在真实重试之后出现,并且复用了这条链条已有的 transport、投递记录与「先 verify 再重发」的幂等约定,没有新增第二条投递路径或新的状态权威。rebase 保留了 main 与 PR 双方的覆盖,refine 用可复现的反例(收下但没读回 → 重复通知)把「只发一次」补成真的。据此给出 APPROVE。

English verdict: APPROVE - exact head 891ebe2; the reader-facing gap (a stalled over-limit answer looked complete) is closed with one bounded notice after three stalled attempts, the notice reuses the existing inbox transport and the existing accepted-but-unread reconciliation so it is never posted twice (mutation check reproduces the duplicate when that step is removed), the rebase onto main kept both main's single-message test and this PR's route test, 1093 focused tests, CI-scope ruff, mypy, three smokes and the pre-merge gate pass with 0 failures, and residual risk is limited to the stubbed provider boundary, the stall-counter's progress semantics, and concurrently running CI whose pre-existing main failures are unrelated to this diff.

@huangruiteng
huangruiteng force-pushed the codex/steward-stall-notice-0921 branch from 891ebe2 to f3cb499 Compare September 21, 2026 18:46
An over-limit manager answer is delivered as ordered parts, and the note that
says where the full answer lives only travels with the last part. When the
provider keeps rejecting the next part, the reader is left holding the leading
fragments with nothing that says the answer was cut off.

Count the stalls in the delivery record and, once the sequence has failed more
than twice, post one bounded notice naming how many parts went and that the rest
is still retried. The count is persisted on every failed attempt because a retry
reloads the record from disk, and it is dropped when the record no longer
describes this split.

Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
Unit coverage for the notice rule (quiet before three stalls, quiet when
nothing or everything was delivered, spoken once, re-offered only when the
attempt itself failed) plus a route-level test that runs the manager topic
through three stalling retries and one clean fourth attempt.

Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
The transport records the provider locator of a sent message before it reads
that message back, so a notice the provider accepted can come back
`sent_unverified` with the text already on the channel. Counting that as a
rejected notice let every later stalled attempt post the reader another copy,
which is the one thing the notice is meant not to do.

Keep the notice locator in the delivery record and verify it before sending
again, exactly as a part is reconciled before it is re-sent, so the reader sees
one notice per stalled sequence. The shared "the provider may already hold this
text" predicate is the same one the part path uses, and the stall counter's
comment now names what it counts.

Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
@huangruiteng
huangruiteng force-pushed the codex/steward-stall-notice-0921 branch from f3cb499 to 7fbfae4 Compare September 21, 2026 18:47

@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: 7fbfae49b0ce3248bd9bfd5c72e7d7e7ba70ae6a(发布前刚重新读取远端 head)。此前的 approval 记录绑定在旧 head 8c5d7eea0,本次 rebase 与 refine 后已按流程重发。

动机

管家行要求管家通道的投递自愈、幂等且状态诚实。这条链前面的切片已经交付了「超长答复按段投递」,以及「provider 已接受但读回失败的分段不再重发」。这条 PR 收口最后一个读者可见的缺口:分段序列卡住时,读者什么都不知道

「完整答复保存在 LoopX 管家会话中」这句话只挂在最后一段上。当 provider 持续拒绝下一段时,读者手里只剩开头几段裸文本,没有任何东西说明这条答复被截断;投递记录写着 pending,通道上却看不出这个信号。作者主张:同一切分连续失败超过两次后,补发一次有界通知,说明已发出多少段、剩余内容在哪里。这是真实缺口上的真实增量。

改动思路

入口是 process_lark_goal_topic_event 的长度恢复分支:单条消息因长度被拒后,它把已校验正文降级为纯文本并交给 deliver_manager_reply_after_length_failure。权威状态是 manager_reply_delivery 写出的那份私有投递记录。决策边界是新纯函数 plan_stalled_part_notice,发送复用既有的 reply_lark_event_inbox,重试由既有的 inbox 事件重试承担。

本轮 refine 把这条 PR 自己承诺的「只发一次」补完整:通知复用分段那套「先落盘 provider 定位、下一次先 verify 再决定是否重发」的既有约定(reconciled_stall_notice + deliver_stall_notice)。原因是可复现的:transport 在「provider 收下、读回未确认」时会把定位写进记录,而旧实现不读这份定位,于是后续每次卡住都会再发一条同样的通知——正是这条 PR 要避免的事。同一条判定(_reply_on_channel)现在被分段和通知共用,而不是各写一份。

具体改动

exact head 相对 origin/main 改 3 个文件、+542/-20:生产代码只落在 loopx/extensions/lark/manager_reply_parts.py(+205/-20),测试 +337(test_lark_manager_reply_parts.pytest_lark_goal_topic_runtime.py)。

新增的持久事实只有三个可选键(delivery_part_stall_countdelivery_part_stall_noticedelivery_part_stall_notice_attempt)与 pending 结果上的 delivery_notice_sent;没有 schema 版本变化、没有新文件、没有新依赖。计数在判断之前累加、判断之后无条件落盘:重试每次都从磁盘重载记录,只在发通知那次写入会让计数永远停在 1,通知不可能触发。切分与记录不符(sent = 0 重启)时丢弃计数,避免上一次切分的失败历史替这一次开口。

关键代码讲解

  1. plan_stalled_part_notice:已发过通知、卡住次数不足 3、sent/count 不是合法整数(含排除 bool)、或 0 < sent < count 不成立时都返回 None。因此「一段都没发出去」和「其实已经全发出去但完成态未验证」这两种情况都保持安静——前者读者没有可误解的片段,后者读者已经拿到全文。
  2. deliver_stall_notice:先 verify 已记录的定位,未确认才发送;发送时用 delivery_attempt_recorder 在「provider 收下之后、读回之前」把定位落盘,所以「收下但没读回」的通知下一次会被确认,而不是再发一遍。发送前清掉旧定位,保证记录始终指向最近一次未确认的通知。
  3. deliver_manager_reply_after_length_failure:计数 → 通知 → 记录结果 → 落盘 → 仍然返回同一个 incomplete reason(机器语义不变,status 仍是 pending)。是否算「读者已被告知」由 _reply_on_channelok 或读回确认)判定。
  4. manager_part_delivery_pending_result:新增 delivery_notice_sent,取自落盘事实而非本次尝试,老记录缺键时为 false,形状向后兼容。
  5. 测试:决策表(1/2 次静默、3 次开口、没发出去/已全发保持静默)、路由级 test_a_stalled_part_sequence_tells_the_reader_what_was_delivered(走完整 process_lark_goal_topic_event 并回读 delivery.json)、被拒通知会重试、以及本轮新增的 test_a_notice_the_provider_took_but_did_not_read_back_is_confirmed

rebase 与 refine 说明

已 rebase 到最新 main。第一次 rebase(76bd61ddd)的冲突只在 tests/extensions/test_lark_goal_topic_runtime.py:main 新增的 test_a_long_manager_answer_is_delivered_as_one_message(普通长答复保持单条)与本 PR 的分段卡住通知测试落在同一位置,两者独立、均已保留;main 在同一个生产文件里的 short_message_limit=None 改动无冲突且语义互补。

之后 main 又落了两次与本 PR 无关的提交(贡献者任务文档 + docs-governance smoke、以及 #4857 跳过 public Node 上的 SQLite lease inspection),均已无冲突地同步到本 head;每次同步后都重跑了两条 Lark 套件(79 passed),本 head 与 origin/main 的 diff 仍是 3 个文件、+542/-20。顺带一提,#4857 正好修掉了此前在 main 上一直红的 node-minimum-compatibility

验证

  • pytest -q tests/extensions tests/test_manager_team_plan_guidance.py tests/test_manager_channel_binding.py tests/test_chat_lark_api_contract.py tests/capabilities/test_outbound_guidance.py1093 passed
  • ruff check tests loopx/canary loopx/control_plane loopx/domain_packs loopx/presentationmypycanary premerge --from-git-diff --git-diff-base origin/main(17 条命令,0 failure,0 manual hold)通过。
  • examples/lark-extension-activation-smoke.pyexamples/control_plane/cli-output-budget-regression-smoke.pyexamples/semantic-vocabulary-drift-smoke.py 通过。
  • 基线与改动对照:基线 4 次重试全部静默,读者只拿到 2/8 段;改动后第 1、2 次静默,第 3 次发出且仅一条,第 4 次不重复。
  • mutation check:撤掉本轮新增的 reconciled_stall_notice 调用后,新回归会以「通知发了两遍」失败;恢复后通过——这证明该回归真正盯着「只发一次」这条不变量,而不是只在好路径上通过。

对主干的风险

读者打扰面有界:前两次卡住保持静默(临时抖动不会变成多余消息),一段未发出时保持静默,通知每切分最多一条;通知文本是固定模板,明确写出 已发出 X/Y 段,因此即使序列仍在缓慢推进,读者得到的也是「进度 + 出处」而不是误导。

兼容性:新增的都是可选键与新增字段,老记录按「从未通知、计数为 0」处理;分段投递本身、单条消息路径、其他 Lark 路由、权限与 authority 均未改动。回滚只需 revert,旧代码仍能读取新记录(多余键被忽略)。

残余风险(已记录):路由级测试用 stub runner 驱动真实事件入口与真实投递记录,没有打真实 lark-cli,所以「收下但读回未确认」的确认路径是按 transport 的既有结果语义验证的;计数统计的是「未能完成的尝试」而不是「连续零进展」,因此缓慢推进的答复可能在第三次卡住时被通知(通知文本会写明已发段数);远端 CI 在本轮审阅时仍在跑,main 上既有的两条 shard 失败与 node-minimum-compatibility 的 SQLite 资格问题会独立让 merge-gate 变红,与本 diff 无关。

我的整体评价

这是一个小而完整的收口:读者不再被迫把残缺答复当成完整答复,通知有界、只在真实重试之后出现,并且复用了这条链条已有的 transport、投递记录与「先 verify 再重发」的幂等约定,没有新增第二条投递路径或新的状态权威。rebase 保留了 main 与 PR 双方的覆盖,refine 用可复现的反例(收下但没读回 → 重复通知)把「只发一次」补成真的。据此给出 APPROVE。

English verdict: APPROVE - exact head 7fbfae4; the reader-facing gap (a stalled over-limit answer looked complete) is closed with one bounded notice after three stalled attempts, the notice reuses the existing inbox transport and the existing accepted-but-unread reconciliation so it is never posted twice (mutation check reproduces the duplicate when that step is removed), the rebase onto main kept both main's single-message test and this PR's route test, 1093 focused tests, CI-scope ruff, mypy, three smokes and the pre-merge gate pass with 0 failures, and residual risk is limited to the stubbed provider boundary, the stall-counter's progress semantics, and concurrently running CI whose pre-existing main failures are unrelated to this diff.

@huangruiteng
huangruiteng merged commit a550de5 into main Sep 21, 2026
4 checks passed
@huangruiteng
huangruiteng deleted the codex/steward-stall-notice-0921 branch September 21, 2026 18:48
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