@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +9 -1
- package/crates/team-agent/src/cli/diagnose.rs +191 -31
- package/crates/team-agent/src/cli/mod.rs +93 -34
- package/crates/team-agent/src/cli/send.rs +69 -2
- package/crates/team-agent/src/cli/status_port.rs +62 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +12 -19
- package/crates/team-agent/src/coordinator/tick.rs +13 -0
- package/crates/team-agent/src/lifecycle/launch.rs +14 -6
- package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +53 -2
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +108 -6
- package/crates/team-agent/src/lifecycle/restart/selection.rs +47 -18
- package/crates/team-agent/src/lifecycle/restart.rs +16 -6
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +287 -53
- package/crates/team-agent/src/lifecycle/tests/restart.rs +144 -5
- package/crates/team-agent/src/lifecycle/types.rs +1 -0
- package/crates/team-agent/src/messaging/delivery.rs +209 -30
- package/crates/team-agent/src/messaging/leader_receiver.rs +60 -25
- package/crates/team-agent/src/messaging/tests/runtime.rs +446 -0
- package/crates/team-agent/src/messaging/watchers.rs +36 -19
- package/crates/team-agent/src/provider/adapter.rs +103 -41
- package/crates/team-agent/src/provider/session/capture.rs +328 -66
- package/crates/team-agent/src/provider/session/resume.rs +30 -28
- package/crates/team-agent/src/provider/session_scan/common.rs +117 -1
- package/crates/team-agent/src/provider/session_scan/copilot.rs +1 -0
- package/crates/team-agent/src/provider/session_scan.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +17 -0
- package/crates/team-agent/src/state/projection.rs +59 -4
- 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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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 = ?
|
|
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
|
|
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":
|
|
479
|
+
"count": requeued,
|
|
467
480
|
}),
|
|
468
481
|
)?;
|
|
469
482
|
}
|
|
470
|
-
|
|
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
|
|