@team-agent/installer 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +9 -1
  4. package/crates/team-agent/src/cli/diagnose.rs +191 -31
  5. package/crates/team-agent/src/cli/mod.rs +93 -34
  6. package/crates/team-agent/src/cli/send.rs +69 -2
  7. package/crates/team-agent/src/cli/status_port.rs +62 -0
  8. package/crates/team-agent/src/cli/tests/status_send.rs +12 -19
  9. package/crates/team-agent/src/coordinator/tick.rs +13 -0
  10. package/crates/team-agent/src/lifecycle/launch.rs +14 -6
  11. package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -1
  12. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -2
  13. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +108 -6
  14. package/crates/team-agent/src/lifecycle/restart/selection.rs +47 -18
  15. package/crates/team-agent/src/lifecycle/restart.rs +16 -6
  16. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +287 -53
  17. package/crates/team-agent/src/lifecycle/tests/restart.rs +144 -5
  18. package/crates/team-agent/src/lifecycle/types.rs +1 -0
  19. package/crates/team-agent/src/messaging/delivery.rs +209 -30
  20. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -25
  21. package/crates/team-agent/src/messaging/tests/runtime.rs +446 -0
  22. package/crates/team-agent/src/messaging/watchers.rs +36 -19
  23. package/crates/team-agent/src/provider/adapter.rs +103 -41
  24. package/crates/team-agent/src/provider/session/capture.rs +328 -66
  25. package/crates/team-agent/src/provider/session/resume.rs +30 -28
  26. package/crates/team-agent/src/provider/session_scan/common.rs +117 -1
  27. package/crates/team-agent/src/provider/session_scan/copilot.rs +1 -0
  28. package/crates/team-agent/src/provider/session_scan.rs +1 -0
  29. package/crates/team-agent/src/state/persist.rs +17 -0
  30. package/crates/team-agent/src/state/projection.rs +59 -4
  31. package/package.json +4 -4
@@ -1683,6 +1683,58 @@ fn slice1_inject_failures_are_bounded_and_stop_retrying_same_message() {
1683
1683
  );
1684
1684
  }
1685
1685
 
1686
+ #[test]
1687
+ fn target_missing_session_window_blocks_without_generic_inject_exhaustion() {
1688
+ let ws = tmp_ws("targetmissing");
1689
+ let store = store_for(&ws);
1690
+ let log = EventLog::new(&ws);
1691
+ let state = serde_json::json!({
1692
+ "session_name": "team-targetmissing",
1693
+ "leader_receiver": {"pane_id": "%leader"},
1694
+ "agents": {"w1": {"provider": "fake", "window": "w1"}}
1695
+ });
1696
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
1697
+ let message_id = store
1698
+ .create_message(None, "leader", "w1", "ping", None, false, None)
1699
+ .unwrap();
1700
+ let transport = OfflineTransport::new()
1701
+ .with_session_present(true)
1702
+ .with_targets(vec![pane_info("%2", "team-targetmissing", "other")]);
1703
+
1704
+ let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
1705
+ .expect("target-missing classification should be a delivery outcome");
1706
+
1707
+ assert!(!out.ok);
1708
+ assert_eq!(out.status, DeliveryStatus::Blocked);
1709
+ assert_eq!(out.message_status.0, "queued_pane_missing");
1710
+ assert_eq!(out.reason, Some(DeliveryRefusal::TmuxTargetMissing));
1711
+ assert!(
1712
+ transport.inject_targets().is_empty(),
1713
+ "target-missing must block before physical inject; targets={:?}",
1714
+ transport.inject_targets()
1715
+ );
1716
+
1717
+ let conn = crate::db::schema::open_db(store.db_path()).unwrap();
1718
+ let (status, error): (String, Option<String>) = conn
1719
+ .query_row(
1720
+ "select status, error from messages where message_id = ?1",
1721
+ [&message_id],
1722
+ |row| Ok((row.get(0)?, row.get(1)?)),
1723
+ )
1724
+ .unwrap();
1725
+ assert_eq!(status, "queued_pane_missing");
1726
+ assert_eq!(error.as_deref(), Some("tmux_target_missing"));
1727
+ let events = log.tail(0).unwrap();
1728
+ assert!(
1729
+ events.iter().any(|event| {
1730
+ event.get("event").and_then(serde_json::Value::as_str) == Some("send.inject_blocked")
1731
+ && event.get("reason").and_then(serde_json::Value::as_str)
1732
+ == Some("tmux_target_missing")
1733
+ }),
1734
+ "target-missing block must be explicit in events; events={events:?}"
1735
+ );
1736
+ }
1737
+
1686
1738
  #[test]
1687
1739
  fn u1_projection_refused_emits_delivery_event() {
1688
1740
  let ws = tmp_ws("u1projref");
@@ -2730,3 +2782,397 @@ fn e15_direct_inject_is_gated_by_deliver_failure_not_unconditional() {
2730
2782
  "E23: private direct inject must stay deleted; all callers use deliver_to_leader_fallback_pane"
2731
2783
  );
2732
2784
  }
2785
+
2786
+ // ════════════════════════════════════════════════════════════════════════
2787
+ // 0.5.4 gate054 — cross-team notify boundary.
2788
+ //
2789
+ // Triage: .team/artifacts/0.5.4-gate-crossteam-notify-triage.md
2790
+ //
2791
+ // Shape (regression):
2792
+ // - Gate team's `leader_receiver` records `pane_id=%1`, `tmux_socket=/tmp/gate`,
2793
+ // `status="rebind_required"` (disposable leader was killed post-restart).
2794
+ // - The main tmux server has a live `%1` on the workspace-canonical socket.
2795
+ // - `deliver_pending_message` MUST refuse with `channel=rebind_required` and
2796
+ // MUST NOT probe the main/default tmux server for the same pane id.
2797
+ // ════════════════════════════════════════════════════════════════════════
2798
+
2799
+ #[test]
2800
+ fn gate054_rebind_required_status_refuses_leader_delivery_without_cross_server_probe() {
2801
+ let ws = tmp_ws("gate054status");
2802
+ let store = store_for(&ws);
2803
+ let log = EventLog::new(&ws);
2804
+ // Explicit endpoint socket (non-canonical for this workspace) with a
2805
+ // stale receiver demoted to `rebind_required` after the gate's leader
2806
+ // window was killed.
2807
+ let state = serde_json::json!({
2808
+ "session_name": "team-gate054",
2809
+ "tmux_endpoint": "/tmp/gate-socket-does-not-exist",
2810
+ "leader_receiver": {
2811
+ "pane_id": "%1",
2812
+ "tmux_socket": "/tmp/gate-socket-does-not-exist",
2813
+ "status": "rebind_required"
2814
+ },
2815
+ "agents": {
2816
+ "probe": {"provider": "fake", "pane_id": "%0", "window": "probe"}
2817
+ }
2818
+ });
2819
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
2820
+ let message_id = store
2821
+ .create_message(
2822
+ None,
2823
+ "probe",
2824
+ "leader",
2825
+ "gate054 must not leak",
2826
+ None,
2827
+ false,
2828
+ None,
2829
+ )
2830
+ .unwrap();
2831
+
2832
+ // The transport we pass is the "main"/default server backend which does
2833
+ // have a live `%1`. If the fix regresses and delivery falls back cross-
2834
+ // server by pane id, it would inject here.
2835
+ let default_backend = OfflineTransport::new()
2836
+ .with_targets(vec![pane_info("%1", "team-main", "leader")]);
2837
+
2838
+ let out = deliver_pending_message(&ws, &store, &default_backend, &message_id, &log, &state)
2839
+ .expect("status guard must be a business refusal, not panic");
2840
+
2841
+ assert!(
2842
+ !out.ok,
2843
+ "0.5.4 gate054: leader_receiver.status='rebind_required' must refuse delivery"
2844
+ );
2845
+ assert_eq!(out.channel.as_deref(), Some("rebind_required"));
2846
+ assert_eq!(out.message_status.0, "failed");
2847
+ assert_eq!(out.reason, Some(DeliveryRefusal::LeaderNotAttached));
2848
+ // Cross-server invariant: even though the default backend has a live `%1`
2849
+ // matching the recorded receiver pane id, delivery MUST NOT inject there.
2850
+ assert!(
2851
+ default_backend.inject_targets().is_empty(),
2852
+ "0.5.4 gate054: leader delivery must not cross to a different tmux server by pane id; \
2853
+ inject_targets={:?}",
2854
+ default_backend.inject_targets()
2855
+ );
2856
+ let events = log.tail(0).unwrap();
2857
+ assert!(
2858
+ events.iter().any(|event| {
2859
+ event.get("event").and_then(serde_json::Value::as_str)
2860
+ == Some("leader_receiver.delivery_blocked")
2861
+ && event.get("reason").and_then(serde_json::Value::as_str)
2862
+ == Some("leader_receiver_not_attached")
2863
+ && event.get("channel").and_then(serde_json::Value::as_str)
2864
+ == Some("rebind_required")
2865
+ && event.get("status").and_then(serde_json::Value::as_str)
2866
+ == Some("rebind_required")
2867
+ }),
2868
+ "0.5.4 gate054: refused status must audit as leader_receiver_not_attached / rebind_required; events={events:?}"
2869
+ );
2870
+ }
2871
+
2872
+ #[test]
2873
+ fn gate054_attached_status_still_delivers_when_pane_is_live() {
2874
+ // Companion negative: prove the new status gate does NOT over-refuse when
2875
+ // `status="attached"` and the workspace-canonical socket has the pane.
2876
+ let ws = tmp_ws("gate054ok");
2877
+ let store = store_for(&ws);
2878
+ let log = EventLog::new(&ws);
2879
+ let state = serde_json::json!({
2880
+ "session_name": "team-gate054ok",
2881
+ "leader_receiver": {
2882
+ "pane_id": "%leader",
2883
+ "status": "attached"
2884
+ },
2885
+ "agents": {"w1": {"provider": "fake", "pane_id": "%1"}}
2886
+ });
2887
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
2888
+ let message_id = store
2889
+ .create_message(None, "w1", "leader", "hi", None, false, None)
2890
+ .unwrap();
2891
+ let transport = OfflineTransport::new()
2892
+ .with_targets(vec![pane_info("%leader", "team-gate054ok", "leader")]);
2893
+
2894
+ let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
2895
+ .expect("attached leader must still deliver");
2896
+
2897
+ assert!(
2898
+ out.ok,
2899
+ "0.5.4 gate054: status='attached' with a live pane must not be over-refused: {out:?}"
2900
+ );
2901
+ assert_eq!(out.message_status.0, "delivered");
2902
+ }
2903
+
2904
+ // ════════════════════════════════════════════════════════════════════════
2905
+ // 0.5.5 gate054 B-arm — deliver_to_leader_fallback_pane must not cross to
2906
+ // another tmux server by pane id when a socket is recorded.
2907
+ //
2908
+ // Triage: real-machine acceptance B-fail — primary path (delivery.rs) refused
2909
+ // with rebind_required, but report_result's fallback_pane bailout selected a
2910
+ // same-numbered pane (%1) on the default tmux server (the main team's leader)
2911
+ // and returned leader_notified:true / delivered_via=fallback_pane.
2912
+ // ════════════════════════════════════════════════════════════════════════
2913
+
2914
+ #[test]
2915
+ fn gate054_fallback_pane_socket_recorded_does_not_cross_to_default_server() {
2916
+ use crate::messaging::deliver_to_leader_fallback_pane;
2917
+ let ws = tmp_ws("gate054fallback");
2918
+ let store = store_for(&ws);
2919
+ let log = EventLog::new(&ws);
2920
+ // Recorded socket points at a non-existent endpoint (mimics the gate
2921
+ // team's private socket after its leader session was killed). The main
2922
+ // team's default tmux server MAY have a same-numbered `%1` — the fix
2923
+ // must refuse to inject there.
2924
+ let bogus_socket = format!("/tmp/team-agent-gate054-nonexistent-{}", std::process::id());
2925
+ let state = serde_json::json!({
2926
+ "session_name": "team-gate054",
2927
+ "leader_receiver": {
2928
+ "pane_id": "%1",
2929
+ "tmux_socket": bogus_socket,
2930
+ "status": "rebind_required"
2931
+ }
2932
+ });
2933
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
2934
+ let message_id = store
2935
+ .create_message(None, "coordinator", "leader", "fallback payload", None, false, None)
2936
+ .unwrap();
2937
+
2938
+ let outcome = deliver_to_leader_fallback_pane(
2939
+ &ws,
2940
+ &state,
2941
+ &message_id,
2942
+ Some("res_gate054_fallback"),
2943
+ "S3 restart complete",
2944
+ false, // primary was NOT ok (report_result path)
2945
+ Some("primary_delivery_error:leader_not_attached"),
2946
+ &log,
2947
+ )
2948
+ .expect("fallback pane primitive must not panic");
2949
+
2950
+ // Must NOT succeed by crossing to another server.
2951
+ assert!(
2952
+ !outcome.ok,
2953
+ "0.5.5 gate054: fallback_pane must not cross a recorded socket boundary: outcome={outcome:?}"
2954
+ );
2955
+ // Loud-not-silent: caller wire status resolves to rebind_required
2956
+ // (results.rs matches channel or Blocked → 'rebind_required').
2957
+ assert!(
2958
+ outcome.channel.as_deref() == Some("rebind_required")
2959
+ || matches!(outcome.status, DeliveryStatus::Blocked),
2960
+ "0.5.5 gate054: cross-boundary refusal must surface as rebind_required, got outcome={outcome:?}"
2961
+ );
2962
+ // Audit noise: fallback_pane_attempt + fallback_pane_failed with the
2963
+ // recorded socket_bound=true forensic field.
2964
+ let events = log.tail(0).unwrap();
2965
+ let attempt = events
2966
+ .iter()
2967
+ .find(|e| {
2968
+ e.get("event").and_then(serde_json::Value::as_str)
2969
+ == Some("leader_receiver.fallback_pane_attempt")
2970
+ });
2971
+ assert!(
2972
+ attempt.is_some(),
2973
+ "fallback_pane_attempt audit must fire even on socket-bounded refusal; events={events:?}"
2974
+ );
2975
+ let failed = events.iter().find(|e| {
2976
+ e.get("event").and_then(serde_json::Value::as_str)
2977
+ == Some("leader_receiver.fallback_pane_failed")
2978
+ });
2979
+ assert!(
2980
+ failed.is_some(),
2981
+ "fallback_pane_failed audit must fire when the recorded socket rejects inject; events={events:?}"
2982
+ );
2983
+ assert_eq!(
2984
+ failed
2985
+ .and_then(|e| e.get("socket_bound"))
2986
+ .and_then(serde_json::Value::as_bool),
2987
+ Some(true),
2988
+ "socket_bound forensic field must record the boundary state"
2989
+ );
2990
+ }
2991
+
2992
+ #[test]
2993
+ fn gate054_rebind_replay_requeues_failed_leader_message_on_attach() {
2994
+ // Round-2 real-machine shape: direct report_result → primary refused with
2995
+ // rebind_required → row is `status=failed, error=leader_not_attached`.
2996
+ // `attach-leader` must requeue it back to `status=accepted` so the
2997
+ // coordinator tick's `deliver_pending_messages` replays it to the new
2998
+ // leader pane. Same message_id — no second replay mechanism, no new
2999
+ // watcher, exactly-once via leader_notification_log PK.
3000
+ use crate::model::ids::TeamKey;
3001
+ let ws = tmp_ws("gate054replay");
3002
+ let store = store_for(&ws);
3003
+ let log = EventLog::new(&ws);
3004
+ let team_id = "gate055r2b";
3005
+
3006
+ // Seed the same shape the primary-path refusal leaves behind.
3007
+ let message_id = store
3008
+ .create_message(
3009
+ None,
3010
+ "probe-worker",
3011
+ "leader",
3012
+ "B2_NOTIFY report_result payload",
3013
+ None,
3014
+ false,
3015
+ Some(team_id),
3016
+ )
3017
+ .unwrap();
3018
+ store
3019
+ .mark(&message_id, "failed", Some("leader_not_attached"))
3020
+ .unwrap();
3021
+ // Sanity: precondition matches acceptance evidence.
3022
+ let conn = crate::db::schema::open_db(store.db_path()).unwrap();
3023
+ let (status_before, error_before): (String, Option<String>) = conn
3024
+ .query_row(
3025
+ "select status, error from messages where message_id = ?1",
3026
+ [&message_id],
3027
+ |r| Ok((r.get(0)?, r.get(1)?)),
3028
+ )
3029
+ .unwrap();
3030
+ assert_eq!(status_before, "failed");
3031
+ assert_eq!(error_before.as_deref(), Some("leader_not_attached"));
3032
+
3033
+ // The attach-leader path calls requeue_delivery_exhausted_watchers with
3034
+ // the newly-claimed pane. Even with NO exhausted watchers to requeue,
3035
+ // it must flip the blocked leader row back to accepted.
3036
+ let team = TeamKey::new(team_id);
3037
+ let new_pane = PaneId::new("%1"); // new leader window on the private socket
3038
+ let notices = crate::messaging::requeue_delivery_exhausted_watchers(
3039
+ &ws, &store, &log, &team, &new_pane,
3040
+ )
3041
+ .unwrap();
3042
+ assert!(
3043
+ notices.is_empty(),
3044
+ "no exhausted watchers seeded → no watcher notices; only the message row should have been requeued"
3045
+ );
3046
+
3047
+ let (status_after, error_after): (String, Option<String>) = conn
3048
+ .query_row(
3049
+ "select status, error from messages where message_id = ?1",
3050
+ [&message_id],
3051
+ |r| Ok((r.get(0)?, r.get(1)?)),
3052
+ )
3053
+ .unwrap();
3054
+ assert_eq!(
3055
+ status_after, "accepted",
3056
+ "gate054 round-2: rebind_required leader row must flip back to 'accepted' on attach-leader"
3057
+ );
3058
+ assert!(
3059
+ error_after.is_none(),
3060
+ "gate054 round-2: error must be cleared on requeue so deliver_pending_message replays cleanly"
3061
+ );
3062
+
3063
+ // Audit noise: leader_receiver.blocked_messages_requeued fired with the
3064
+ // exact team + claimed pane + count for status/monitor observability.
3065
+ let events = log.tail(0).unwrap();
3066
+ let requeued = events.iter().find(|e| {
3067
+ e.get("event").and_then(serde_json::Value::as_str)
3068
+ == Some("leader_receiver.blocked_messages_requeued")
3069
+ });
3070
+ assert!(
3071
+ requeued.is_some(),
3072
+ "attach-leader requeue of blocked leader messages must audit; events={events:?}"
3073
+ );
3074
+ assert_eq!(
3075
+ requeued
3076
+ .and_then(|e| e.get("count"))
3077
+ .and_then(serde_json::Value::as_u64),
3078
+ Some(1)
3079
+ );
3080
+ assert_eq!(
3081
+ requeued
3082
+ .and_then(|e| e.get("team_id"))
3083
+ .and_then(serde_json::Value::as_str),
3084
+ Some(team_id)
3085
+ );
3086
+ }
3087
+
3088
+ #[test]
3089
+ fn gate054_status_surfaces_pending_leader_notifications() {
3090
+ // Round-2: user-facing visibility — status must show the blocked leader
3091
+ // notification as pending, not just as `messages.failed=1`.
3092
+ use crate::cli::status_port::status_scoped;
3093
+ let ws = tmp_ws("gate054status");
3094
+ let store = store_for(&ws);
3095
+ let team_id = "gate055r2b";
3096
+
3097
+ let message_id = store
3098
+ .create_message(
3099
+ None,
3100
+ "probe-worker",
3101
+ "leader",
3102
+ "B2_NOTIFY report_result payload",
3103
+ None,
3104
+ false,
3105
+ Some(team_id),
3106
+ )
3107
+ .unwrap();
3108
+ store
3109
+ .mark(&message_id, "failed", Some("leader_not_attached"))
3110
+ .unwrap();
3111
+
3112
+ // Minimal runtime state so status can render.
3113
+ let state = serde_json::json!({
3114
+ "session_name": format!("team-{team_id}"),
3115
+ "active_team_key": team_id,
3116
+ "teams": {team_id: {"team_key": team_id}},
3117
+ "leader_receiver": {
3118
+ "pane_id": "%1",
3119
+ "status": "rebind_required",
3120
+ }
3121
+ });
3122
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
3123
+
3124
+ let payload = status_scoped(&ws, &state, Some(team_id), false, false).unwrap();
3125
+ let pending = payload
3126
+ .get("pending_leader_notifications")
3127
+ .and_then(serde_json::Value::as_array)
3128
+ .expect("status must expose pending_leader_notifications as an array");
3129
+ assert_eq!(
3130
+ pending.len(),
3131
+ 1,
3132
+ "the blocked leader message must appear in pending_leader_notifications"
3133
+ );
3134
+ let entry = &pending[0];
3135
+ assert_eq!(
3136
+ entry.get("message_id").and_then(serde_json::Value::as_str),
3137
+ Some(message_id.as_str())
3138
+ );
3139
+ assert_eq!(
3140
+ entry.get("channel").and_then(serde_json::Value::as_str),
3141
+ Some("rebind_required")
3142
+ );
3143
+ assert!(entry
3144
+ .get("action")
3145
+ .and_then(serde_json::Value::as_str)
3146
+ .is_some_and(|action| action.contains("attach-leader")
3147
+ || action.contains("takeover")));
3148
+ }
3149
+
3150
+ #[test]
3151
+ fn gate054_fallback_pane_grep_guard_no_cross_server_chain_when_socket_recorded() {
3152
+ // Structural grep: the recorded-socket branch of the fallback inject
3153
+ // chain must NOT compose the workspace or default tmux backends after
3154
+ // the endpoint backend rejects. This is the byte-level fence against
3155
+ // silent regression.
3156
+ let src = include_str!("../leader_receiver.rs");
3157
+ let inject_start = src
3158
+ .find("let inject_result: Result<InjectReport, TransportError>")
3159
+ .expect("fallback inject chain must remain single-typed");
3160
+ let inject_end = src[inject_start..]
3161
+ .find("};\n\n match inject_result")
3162
+ .map(|off| inject_start + off + 2)
3163
+ .expect("fallback inject chain must terminate before match");
3164
+ let chain = &src[inject_start..inject_end];
3165
+ // The socket-recorded arm is the `Some(socket) => {..}` block. It must
3166
+ // NOT contain a workspace or default fallback.
3167
+ let some_arm_start = chain.find("Some(socket) => {").expect("Some(socket) arm");
3168
+ let some_arm_end = chain[some_arm_start..]
3169
+ .find("None => {")
3170
+ .map(|off| some_arm_start + off)
3171
+ .expect("None arm must follow");
3172
+ let some_arm = &chain[some_arm_start..some_arm_end];
3173
+ assert!(
3174
+ !some_arm.contains("tmux_workspace_transport")
3175
+ && !some_arm.contains("tmux_default_transport"),
3176
+ "0.5.5 gate054: socket-recorded fallback arm must not compose workspace/default backends; arm={some_arm}"
3177
+ );
3178
+ }
@@ -436,45 +436,61 @@ pub fn requeue_after_claim_leader(
436
436
  }),
437
437
  )?;
438
438
  }
439
- // #231 C-5: requeue blocked leader-bound messages that hit I-4 `rebind_required`
440
- // while no leader pane was attached. Same row, same message_id — flip status back
441
- // from `failed`/`leader_not_attached` to `accepted` so `deliver_pending_messages`
442
- // replays it through the SAME pipeline. The leader_notification_log PK is already
443
- // there (primitive wrote it before the unbound check), so this replay can't create
444
- // a duplicate notification — exactly-once across rebind, no new send/notify rows.
445
- let requeued_blocked = conn.execute(
439
+ let requeued_blocked =
440
+ requeue_blocked_leader_messages(&conn, event_log, owner_team_id, claimed_pane_id)?;
441
+ if !out.is_empty() || requeued_blocked > 0 {
442
+ let _ = retry_result_deliveries(workspace, event_log)?;
443
+ }
444
+ Ok(out)
445
+ }
446
+
447
+ /// 0.5.5 gate054 round-2: attach-leader (and claim-leader) requeue for leader messages
448
+ /// that were refused with `rebind_required` while no leader pane was attached.
449
+ ///
450
+ /// #231 C-5 semantics: same row, same message_id — flip `status` back from
451
+ /// `failed`/`leader_not_attached` to `accepted` so `deliver_pending_messages`
452
+ /// replays it through the SAME pipeline. The `leader_notification_log` PK is
453
+ /// already there (primitive wrote it before the unbound check), so this replay
454
+ /// cannot create a duplicate notification — exactly-once across rebind, no new
455
+ /// send/notify rows and no second replay mechanism.
456
+ pub(crate) fn requeue_blocked_leader_messages(
457
+ conn: &rusqlite::Connection,
458
+ event_log: &EventLog,
459
+ owner_team_id: &TeamKey,
460
+ claimed_pane_id: &PaneId,
461
+ ) -> Result<usize, MessagingError> {
462
+ let requeued = conn.execute(
446
463
  "update messages
447
464
  set status = 'accepted',
448
465
  error = null,
449
- updated_at = ?3
466
+ updated_at = ?2
450
467
  where recipient = 'leader'
451
468
  and status = 'failed'
452
469
  and error = 'leader_not_attached'
453
470
  and owner_team_id = ?1",
454
- params![
455
- owner_team_id.as_str(),
456
- claimed_pane_id.as_str(),
457
- chrono::Utc::now().to_rfc3339(),
458
- ],
471
+ params![owner_team_id.as_str(), chrono::Utc::now().to_rfc3339()],
459
472
  )?;
460
- if requeued_blocked > 0 {
473
+ if requeued > 0 {
461
474
  event_log.write(
462
475
  "leader_receiver.blocked_messages_requeued",
463
476
  serde_json::json!({
464
477
  "team_id": owner_team_id.as_str(),
465
478
  "claimed_pane_id": claimed_pane_id.as_str(),
466
- "count": requeued_blocked,
479
+ "count": requeued,
467
480
  }),
468
481
  )?;
469
482
  }
470
- if !out.is_empty() {
471
- let _ = retry_result_deliveries(workspace, event_log)?;
472
- }
473
- Ok(out)
483
+ Ok(requeued)
474
484
  }
475
485
 
476
486
  /// `requeue_delivery_exhausted_watchers`: attach-leader 成功后把已经耗尽投递
477
487
  /// 重试的 watcher 放回 notify_failed, 留给 coordinator tick 重试投递。
488
+ ///
489
+ /// 0.5.5 gate054 round-2: also requeue direct leader messages that were refused
490
+ /// with `rebind_required` (status=failed / error=leader_not_attached). Without
491
+ /// this the direct `report_result` path leaves a failed row that
492
+ /// `deliver_pending_messages` never re-picks — the new leader would never see the
493
+ /// pending notification.
478
494
  pub fn requeue_delivery_exhausted_watchers(
479
495
  _workspace: &Path,
480
496
  store: &MessageStore,
@@ -522,6 +538,7 @@ pub fn requeue_delivery_exhausted_watchers(
522
538
  )?;
523
539
  }
524
540
  drop(stmt);
541
+ let _ = requeue_blocked_leader_messages(&conn, event_log, owner_team_id, claimed_pane_id)?;
525
542
  Ok(out)
526
543
  }
527
544