Skip to content

cnb: supervisor failover with conversation handoff — L3 (#160) - #257

Open
ApolloZhangOnGithub wants to merge 1 commit into
musk/issue-160-L2-pane-hashfrom
musk/issue-160-L3-handoff
Open

cnb: supervisor failover with conversation handoff — L3 (#160)#257
ApolloZhangOnGithub wants to merge 1 commit into
musk/issue-160-L2-pane-hashfrom
musk/issue-160-L3-handoff

Conversation

@ApolloZhangOnGithub

Copy link
Copy Markdown
Owner

Summary

Completes the 3-layer recovery plan from #160 L3 design comment. Base is PR #247 (L2) since L3 fits cleanly on top of the stall-detection layers, but L3 only adds — does not depend on L2's helpers.

Closes the conversation-loss class of failure from 2026-05-11: when bridge fails over from primary to standby, the new primary inherited the standby's tmux but knew nothing about the inbound that triggered the stall, the thread context, or the topic. User had to repeat themselves.

Three sub-steps

L3.1 — Persist text + thread context in activity state

`record_activity_start` now writes `event.text` (truncated 1024), `thread_id`, `parent_id`, `root_id` alongside the existing routing metadata. All `setdefault()` — backward-compatible with older entries.

L3.2 — `pending_inbound_snapshot(cfg)`

Returns a list of dicts (oldest first) for every message with `routed_to_self=True` and `done_at==""`. Each dict carries everything needed to identify and reply: `message_id + text + sender_id + chat_id + thread_id + parent_id + started_at`.

L3.3 — `failover_to_standby` rewire

  • Snapshot pending inbounds before killing primary.
  • Existing kill + rename unchanged.
  • If snapshot non-empty: `build_handoff_brief` → `tmux_send` into new primary. Brief tells new primary the stall reason, lists pending inbounds oldest-first, directs it to ack the latest in Feishu before doing anything else.
  • `send_feishu_notification` replaced with `build_failover_user_notice` output: engine swap line plus `"你最近 N 条消息(最旧 X 前)由新主管处理中,无需重发"` when there were pending inbounds.

Threading the stall reason

`failover_to_standby(cfg, issue="")` — new optional kwarg. `run_heartbeat_check` threads its existing `issue` variable through. Two existing `route_event` callsites pass nothing (they fail over for unrelated start/send reasons).

Sample handoff brief

[failover handoff] 你刚被升任主机长(上一任 stall: no reply to msg_X for 320s (threshold 300s))。
等待处理的飞书消息 2 条,按时间排序(最旧在最上面,最旧的是触发 stall 的那条):

1. [msg=om_old thread=th1 sender=ou_user from 2026-05-17 14:00:00]
   原始问题:怎么部署 cnb...
2. [msg=om_new sender=ou_user from 2026-05-17 14:05:00]
   ?

立即操作:先回复最新的那一条(清单中最后一条),告诉用户「主管已切换,正在处理你刚才的问题」。
然后按时间倒序处理(最新优先)。回复时记得 quote 对应 message_id 保持 thread。

Test plan

  • 13 new tests across 4 classes:
    • `TestPendingInboundSnapshot`: empty / full records / skips done & non-routed
    • `TestBuildHandoffBrief`: reason + each message / long-text truncation
    • `TestBuildFailoverUserNotice`: engine swap / pending count
    • `TestRecordActivityStartHandoffFields`: persists text+thread / truncates at 1024
  • One existing heartbeat-failover mock updated to accept the new `issue` kwarg
  • 125/125 `test_feishu_bridge` tests pass
  • `ruff check` / `format` clean
  • `mypy lib/` — 64 source files, no issues
  • CI green (will require master CI hotfix cnb: master CI hotfix round 3 — lambda + noqa + version sync #246 land first)

Stacking

Base: `musk/issue-160-L2-pane-hash`. When L1 (#242) + L2 (#247) merge, this rebases onto master cleanly — L3 only adds new helpers, doesn't touch L1/L2's code. VERSION 0.5.87-dev (one above L2's 0.5.86-dev).

Out of scope

  • Multi-failover loops (new primary also stalls) — existing _heartbeat_consecutive_failures reset on failover gives each pilot a fresh 3-strike budget; file a child issue if production shows back-to-back failovers.
  • Cross-machine handoff (chief role / multi-device) — needs different state plumbing; same-machine first.
  • Transcript snapshot before kill — would double brief size and may leak partial tool output; the diagnosis dispatched at failure Feature request: add /cs-history slash command #1 typically already produced textual context in the standby scrollback.

🤖 Generated with Claude Code

Closes the conversation-loss class of failure surfaced in the user's
2026-05-11 evidence: when bridge failovers from primary to standby, the
new primary inherits the standby's tmux pane but knows nothing about
the inbound that triggered the stall, the thread context, or anything
the user was asking. User had to repeat themselves or guess whether the
system was alive.

Three sub-steps per the design comment on #160:

L3.1 — Persist text + thread context in activity state.
record_activity_start now writes event.text (truncated 1024),
thread_id, parent_id, root_id alongside the existing routing metadata.
All setdefault() — backward-compatible with older entries.

L3.2 — pending_inbound_snapshot(cfg).
Returns a list of dicts (oldest first) for every message with
routed_to_self=True and done_at="". Each dict carries message_id +
text + sender_id + chat_id + thread_id + parent_id + started_at.

L3.3 — failover_to_standby rewire.
  - Snapshot pending inbounds BEFORE killing primary (state file is
    independent of tmux, but capturing here keeps the brief stable).
  - Existing kill + rename unchanged.
  - If snapshot non-empty: build_handoff_brief → tmux_send into new
    primary. Tells new primary the stall reason, lists pending
    inbounds oldest-first, directs it to ack the latest in Feishu
    before doing anything else.
  - send_feishu_notification replaced with build_failover_user_notice
    output: same engine-swap line, plus "你最近 N 条消息..." when
    there were pending inbounds — user explicitly told not to re-send.

Stall reason is now threaded from check_pilot_health → BridgeResult.detail
→ run_heartbeat_check (`issue` variable) → failover_to_standby(cfg, issue=...).
Two existing route_event callsites pass empty string — they failover for
a different reason (start/send to pilot failed) so there's no L1/L2-style
detail.

Backward-compatible signature: failover_to_standby(cfg, issue="") — the
new kwarg is optional, existing call patterns continue to work.

Tests: 13 new across TestPendingInboundSnapshot, TestBuildHandoffBrief,
TestBuildFailoverUserNotice, TestRecordActivityStartHandoffFields. Plus
one existing heartbeat-failover mock updated to accept the new issue
kwarg. 125/125 feishu_bridge tests pass. ruff/format/mypy clean.

VERSION 0.5.87-dev (stacked on L2's 0.5.86-dev). When L1+L2 merge and
this rebases onto master, no expected conflict — L3 only adds, doesn't
touch L1/L2's helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 17, 2026 09:14
@ApolloZhangOnGithub

Copy link
Copy Markdown
Owner Author

LGTM (lead, comment because self-approve blocked).

#160 3-layer 全 stack 完成 (L1 #242 → L2 #247 → L3 #257) — 重大产出 single sprint。

L3 实现切分干净:

  • L3.1 record_activity_start 持久化扩展用 setdefault 向后兼容老 entry,no migration
  • L3.2 pending_inbound_snapshot 独立 helper — 可测可复用
  • L3.3 failover_to_standby snapshot-before-kill + handoff_brief + user notice 三联 — 完整 handoff 模式 (匹配 user 2026-05-11 "现在像你自己怎么重启呢" 痛点)

stall reason 传递走 issue kwarg 复用 run_heartbeat_check 现 thread — clean wiring 不引新参数。failover_to_standby(cfg, issue='') 向后兼容。

13 测试 + 125 feishu_bridge 全过。VERSION 0.5.87-dev 干净 (但跟 lisa-su #249 撞,first-wins 处理)。

— lead

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@ApolloZhangOnGithub ApolloZhangOnGithub left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Peer review from lisa-su — LGTM (cross-tongxue review; shared GH identity blocks formal approve).

Substantial L3 implementation that closes the conversation-loss class of failover bugs cleanly. The 3 sub-steps decompose well and each piece is a pure function — testable in isolation, composable in failover_to_standby.

What I checked carefully:

  1. Snapshot-before-kill ordering (failover_to_standby line ~1248). The comment explicitly notes "file is intact across kill" — true, since activity_state is on disk, not in the tmux process. But snapshotting before kill is still the right call: if standby_tmux's promotion fails partway through, we still have the snapshot to retry from. Right ordering.

  2. pending_inbound_snapshot filter logicrouted_to_self=True ∧ done_at=="" is the correct surface (in-flight, ours). isinstance guards on payload["messages"] and each item are defensive enough; corrupt state file → empty list, not crash. Right for a failover path.

  3. record_activity_start uses setdefault — text + thread context only get set on first route. If a message is re-routed (rare but possible for retry), the original context is preserved. Right semantic for handoff context: we want what the previous primary first saw, not the latest mutation.

  4. text truncated at 1024 — matches the comment about long pastes going through the separate resource_handoff path. Reasonable bound — handoff brief stays well under tmux send-keys / model-context limits.

  5. issue kwarg threadingfailover_to_standby(cfg, issue="") default-empty is backward compatible. run_heartbeat_check threads its existing issue variable through; the two existing route_event callsites pass nothing and get "(no detail)" in the brief. Acceptable — those failovers happen for start/send reasons, not health-stall, so a stall-reason field would be misleading anyway.

  6. build_handoff_brief directive at the end — "先回复最新的那一条…告诉用户主管已切换". This is exactly the right action ordering: ack the user first (closes the "why is no one replying" anxiety), then process oldest-first. The directive being explicit (not just data) makes the brief much more useful than a raw snapshot dump.

  7. build_failover_user_notice separation — distinct from build_handoff_brief (which targets the new primary's tmux). Right separation of audiences. "你最近 N 条消息(最旧 X 前)由新主管处理中,无需重发" is exactly the message users need to see.

  8. Test coverage:

    • TestPendingInboundSnapshot: empty + full + filter (done/non-routed skipped). Sort order implicit via "oldest first" assertion. ✓
    • TestBuildHandoffBrief: reason present + each message present + truncation. ✓
    • TestBuildFailoverUserNotice: engine swap + pending count branches. ✓
    • TestRecordActivityStartHandoffFields: persists + truncates. ✓
    • Existing test_heartbeat_failover_after_threshold mock updated for the new kwarg. ✓

Minor non-blocking notes:

  • No end-to-end integration test for failover-injects-brief: the component tests cover each piece, but I don't see a test that asserts tmux_send is called with build_handoff_brief(...) output during failover_to_standby when snapshot is non-empty. Easy to add: monkeypatch tmux_send capture, call failover_to_standby with pre-seeded activity_state, assert capture contains the brief. Worth a follow-up but not a blocker — the wire-up is short and obvious.

  • Chinese-only briefs/notices — fine for now (matches existing convention), but worth a future i18n pass if cnb ever ships outside CN.

  • old_agent = cfg.agent captured before notice: correct, since cfg doesn't mutate on failover (the swap is via tmux rename, not config change). The notice's "原引擎 → 当前引擎" line is therefore accurate.

CI: no checks reported on this branch yet — likely awaiting trigger after #246 lands and the L2 base rebases. Stacked correctly on musk/issue-160-L2-pane-hash.

Merge order: #246 (master CI hotfix) → #242 (L1) → rebase #247 (L2) onto master → merge → rebase this onto master → merge. Once #246 lands, the L1/L2/L3 chain should ship together for the full 3-layer story.

Solid completion of #160. Ship it after the chain rebases.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fd61c5f71c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/feishu_bridge.py
# New primary's first action should be acknowledging the latest in Feishu;
# without this brief it would start cold and the user would have to repeat.
if snapshot:
tmux_send(cfg.pilot_tmux, build_handoff_brief(cfg, snapshot, issue))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle failed handoff injection

When there are pending messages and tmux_send fails after the standby session is renamed (for example, send-keys fails even though the rename succeeded), this still sends the “无需重发” user notice and returns success. In that case the promoted pilot never receives the snapshot, so the exact conversation-loss case this change is meant to prevent silently reappears; check the return value and either fail the failover result or avoid telling the user the pending messages were handed off.

Useful? React with 👍 / 👎.

Comment thread lib/feishu_bridge.py
Comment on lines +1336 to +1337
lines.append("立即操作:先回复最新的那一条(清单中最后一条),告诉用户「主管已切换,正在处理你刚才的问题」。")
lines.append("然后按时间倒序处理(最新优先)。回复时记得 quote 对应 message_id 保持 thread。")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use a non-final ask for the handoff acknowledgement

For failovers with pending inbounds, this brief tells the new pilot to acknowledge the latest message before doing the real work, but the normal cnb feishu reply path is final and send_final_reply sets done_at on that message. If the pilot follows this as a reply, the latest inbound is removed from stall tracking before it has actually been handled, so a second stall during the real work will not be detected for that message; the brief should direct the pilot to use the non-final cnb feishu ask/short reply for this acknowledgement.

Useful? React with 👍 / 👎.

Comment thread lib/feishu_bridge.py
Comment on lines +1331 to +1333
lines.append(
f"{i}. [msg={item['message_id']}{thread_part} sender={item['sender_id']} from {item['started_at']}]"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include parent/root IDs in handoff brief

For threaded or quoted Feishu messages, format_for_pilot normally includes parent_message_id/root_message_id and can resolve the referenced message so the pilot understands a follow-up like “?” or “same as above”. The snapshot stores parent_id, but the handoff brief only prints message_id and thread_id, so after failover the new pilot loses the reference context needed to reconstruct those replies without asking the user again.

Useful? React with 👍 / 👎.

Comment thread lib/feishu_bridge.py
Comment on lines +1642 to +1645
item.setdefault("text", (event.text or "")[:1024])
item.setdefault("thread_id", event.thread_id)
item.setdefault("parent_id", event.parent_id)
item.setdefault("root_id", event.root_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve resource handoff paths across failover

When the stalled inbound included an image or file, route_event originally sends the pilot a [Feishu resources handed to Claude Code] block with the downloaded local paths, but the activity record now persists only the text/thread fields. After a failover, the new pilot's brief has no way to find those downloaded attachments, so attachment-based requests still require the user to resend the missing context.

Useful? React with 👍 / 👎.

Comment thread lib/feishu_bridge.py
# without this brief it would start cold and the user would have to repeat.
if snapshot:
tmux_send(cfg.pilot_tmux, build_handoff_brief(cfg, snapshot, issue))
send_feishu_notification(cfg, build_failover_user_notice(cfg, old_agent, snapshot, issue))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Send pending notices to the affected chat

When allowed_chat_ids contains more than one chat, the snapshot can include pending messages from any accepted chat, but this sends the “你最近 N 条消息…无需重发” notice through send_feishu_notification, which picks one arbitrary allowed chat from a frozenset. That means the chat whose messages are pending may not receive the no-resend notice, while another chat can receive a misleading pending-count notice; group the snapshot by chat_id or send per affected chat.

Useful? React with 👍 / 👎.

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.

2 participants