Skip to content

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

Open
huangruiteng wants to merge 2 commits into
mainfrom
codex/steward-stall-notice-0921
Open

huangruiteng wants to merge 2 commits into
mainfrom
codex/steward-stall-notice-0921

Conversation

@huangruiteng

Copy link
Copy Markdown
Collaborator

动机

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

改动思路

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

具体改动

  • loopx/extensions/lark/manager_reply_parts.py
    • 新增 PART_STALL_NOTICE_KEY / PART_STALL_COUNT_KEY / PART_STALL_NOTICE_MIN_STALLS / MANAGER_REPLY_STALL_NOTICE
    • 新增纯函数 plan_stalled_part_notice(delivery_state):已发过通知、卡住次数不足 3、sent/count 不是合法整数、或 0 < sent < count 不成立时都返回 None
    • deliver_manager_reply_after_length_failure:分段序列未完成时累加计数;达到阈值则通过同一个 reply_lark_event_inbox 发一次通知,成功才把 PART_STALL_NOTICE_KEY 置真,并记录 last_delivery_notice_status;无论是否发通知都把计数写回磁盘。
    • deliver_manager_reply_parts:切分与记录不符(sent = 0 重启)时丢弃卡住计数,避免上一次切分的失败历史替这一次开口。
    • manager_part_delivery_pending_result 增加 delivery_notice_sent 读回字段。

对主干的风险

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

验证

  • uv run --extra test python -m pytest tests/extensions/ -q → 978 passed。
  • 基线与改动的对照探针(同一份磁盘记录跑 4 次同样的分段重试):
    • 基线 origin/main 3de02368a:4 次尝试全部静默,读者只拿到 2/8 段裸文本,通知数 0。
    • 改动后:第 1、2 次静默,第 3 次发出且仅发出 1 条通知「本条答复超过可发送长度,目前只发出了前面的 2/8 段;…」,第 4 次不重复。
  • 路由级测试 test_a_stalled_part_sequence_tells_the_reader_what_was_delivered 走完整 process_lark_goal_topic_event 路径,并回读落盘的 delivery.json 确认 delivery_part_stall_notice 为真、status 仍为 pending

我的整体评价

这是对「分段投递已经失败」这一已知缺口的小而完整的收口:读者不再被迫把残缺答案当成完整答案,而通知本身是有界的、只发一次、且在真实重试之后才发。

关键代码讲解

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)

计数在判断通知之前累加、并在判断之后无条件落盘:重试每次都从磁盘重载记录,如果只在发通知的那次写入,计数会永远停在 1,通知不可能触发。通知本身走既有 reply_lark_event_inbox,因此读回、幂等键与失败语义和分段投递完全一致;PART_STALL_NOTICE_KEY 只在读回确认后才置真,被拒绝的通知会在下一次尝试重新提供。

English verdict: APPROVE - bounded, evidence-backed fix for a concrete steward UX gap (a half-delivered over-limit answer looked complete); scoped to the existing part-delivery failure path with base/head counterfactual plus 978 passing extension tests.

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>

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

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