@team-agent/installer 0.5.92 → 0.5.97
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 +381 -157
- package/crates/team-agent/src/cli/diagnose.rs +40 -10
- package/crates/team-agent/src/cli/emit.rs +6 -4
- package/crates/team-agent/src/cli/mod.rs +483 -470
- package/crates/team-agent/src/cli/models.rs +1 -0
- package/crates/team-agent/src/cli/spec.rs +2 -2
- package/crates/team-agent/src/cli/tests/base.rs +20 -10
- package/crates/team-agent/src/cli/tests/divergence.rs +12 -21
- package/crates/team-agent/src/cli/tests/main_preserved.rs +6 -1
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +20 -11
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +197 -30
- package/crates/team-agent/src/cli/triage.rs +31 -31
- package/crates/team-agent/src/cli/types.rs +3 -1
- package/crates/team-agent/src/coordinator/health.rs +92 -30
- package/crates/team-agent/src/coordinator/tick.rs +62 -8
- package/crates/team-agent/src/coordinator/types.rs +2 -0
- package/crates/team-agent/src/kill_audit.rs +41 -2
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +48 -14
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +4 -8
- package/crates/team-agent/src/lifecycle/launch.rs +17 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -0
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +10 -4
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +10 -7
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +91 -4
- package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +128 -11
- package/crates/team-agent/src/platform/process.rs +4 -45
- package/crates/team-agent/src/tmux_backend.rs +113 -3
- package/crates/team-agent/src/transport/test_support.rs +30 -0
- package/crates/team-agent/src/transport.rs +41 -0
- package/package.json +4 -4
|
@@ -516,10 +516,11 @@ pub mod lifecycle_port {
|
|
|
516
516
|
/// (经同一帧 list_targets pane→session)。state 无任何锚 → 退命名判据 + spare_fallback event。
|
|
517
517
|
fn anchor_sessions_from_state(
|
|
518
518
|
state: &Value,
|
|
519
|
+
endpoint: Option<&str>,
|
|
519
520
|
pane_targets: &[crate::transport::PaneInfo],
|
|
520
521
|
event_log: &crate::event_log::EventLog,
|
|
521
522
|
) -> std::collections::BTreeSet<String> {
|
|
522
|
-
let anchor_pane_ids = collect_state_leader_anchor_pane_ids(state);
|
|
523
|
+
let anchor_pane_ids = collect_state_leader_anchor_pane_ids(state, endpoint);
|
|
523
524
|
if anchor_pane_ids.is_empty() {
|
|
524
525
|
// 无锚(state 损坏/未记)→ 退纯命名前缀判据(下游 sessions_to_kill 仍 spare 前缀)。
|
|
525
526
|
let _ = event_log.write(
|
|
@@ -549,9 +550,7 @@ pub mod lifecycle_port {
|
|
|
549
550
|
/// E12 下沉纯函数:bare-shutdown socket 拆除决策。
|
|
550
551
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
551
552
|
pub(crate) enum KillDecision {
|
|
552
|
-
///
|
|
553
|
-
KillServerExclusive,
|
|
554
|
-
/// 有 spare(leader 锚/前缀)或非独享 → 逐 session kill,绝不 kill-server。
|
|
553
|
+
/// Ownership-gated cleanup always uses per-session kills; never whole-server teardown.
|
|
555
554
|
KillIndividually {
|
|
556
555
|
to_kill: Vec<crate::transport::SessionName>,
|
|
557
556
|
spared: Vec<crate::transport::SessionName>,
|
|
@@ -560,8 +559,7 @@ pub mod lifecycle_port {
|
|
|
560
559
|
|
|
561
560
|
///
|
|
562
561
|
/// E12 纯决策(单测下沉):spare = `anchor_sessions` ∪ `team-agent-leader-*` 前缀(并集,锚优先)。
|
|
563
|
-
///
|
|
564
|
-
/// kill 非 spare 的(共享 socket / leader 在 → 绝不整 server 拆)。空 session 集 → 逐 kill(no-op)。
|
|
562
|
+
/// Always returns a per-session plan; ownership markers are checked by the caller.
|
|
565
563
|
pub(crate) fn sessions_to_kill(
|
|
566
564
|
sessions: &[crate::transport::SessionName],
|
|
567
565
|
anchor_sessions: &std::collections::BTreeSet<String>,
|
|
@@ -572,12 +570,9 @@ pub mod lifecycle_port {
|
|
|
572
570
|
};
|
|
573
571
|
let spared: Vec<_> = sessions.iter().filter(|s| is_spared(s)).cloned().collect();
|
|
574
572
|
let to_kill: Vec<_> = sessions.iter().filter(|s| !is_spared(s)).cloned().collect();
|
|
575
|
-
//
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
} else {
|
|
579
|
-
KillDecision::KillIndividually { to_kill, spared }
|
|
580
|
-
}
|
|
573
|
+
// Fail closed: even an apparently exclusive socket is not proof of
|
|
574
|
+
// ownership, so whole-server teardown is never selected.
|
|
575
|
+
KillDecision::KillIndividually { to_kill, spared }
|
|
581
576
|
}
|
|
582
577
|
|
|
583
578
|
#[derive(Debug, Default)]
|
|
@@ -615,118 +610,183 @@ pub mod lifecycle_port {
|
|
|
615
610
|
}
|
|
616
611
|
|
|
617
612
|
fn bare_shutdown_socket_cleanup(
|
|
613
|
+
workspace: &Path,
|
|
618
614
|
transport: &dyn crate::transport::Transport,
|
|
619
615
|
state: &Value,
|
|
620
616
|
event_log: &crate::event_log::EventLog,
|
|
621
617
|
) -> ShutdownSocketCleanup {
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
if
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
);
|
|
645
|
-
let mut error = None;
|
|
646
|
-
for session in &sessions {
|
|
647
|
-
let targets = vec![session.as_str().to_string()];
|
|
648
|
-
if let Err(audit_error) = crate::kill_audit::pre_kill_audit(
|
|
649
|
-
event_log,
|
|
650
|
-
transport,
|
|
651
|
-
"shutdown.shared_socket_individual",
|
|
652
|
-
crate::kill_audit::KILL_SESSION,
|
|
653
|
-
&targets,
|
|
654
|
-
) {
|
|
655
|
-
error.get_or_insert_with(|| {
|
|
656
|
-
format!("pre-kill audit failed: {audit_error}")
|
|
657
|
-
});
|
|
658
|
-
continue;
|
|
659
|
-
}
|
|
660
|
-
if let Err(err) = transport.kill_session(session) {
|
|
661
|
-
if !tmux_absent_error(&err.to_string()) {
|
|
662
|
-
error.get_or_insert_with(|| err.to_string());
|
|
663
|
-
}
|
|
664
|
-
}
|
|
618
|
+
// A bare shutdown is scoped to sessions registered in state. Never turn a
|
|
619
|
+
// socket-wide enumeration into an authorization decision: a shared/default
|
|
620
|
+
// tmux socket may contain unrelated teams and the absence of a marker is
|
|
621
|
+
// deliberately treated as Unknown.
|
|
622
|
+
let mut candidates = Vec::new();
|
|
623
|
+
if let Some(session) = state
|
|
624
|
+
.get("session_name")
|
|
625
|
+
.and_then(Value::as_str)
|
|
626
|
+
.filter(|name| !name.is_empty())
|
|
627
|
+
{
|
|
628
|
+
candidates.push(crate::transport::SessionName::new(session));
|
|
629
|
+
}
|
|
630
|
+
if let Some(teams) = state.get("teams").and_then(Value::as_object) {
|
|
631
|
+
for team in teams.values() {
|
|
632
|
+
if let Some(session) = team
|
|
633
|
+
.get("session_name")
|
|
634
|
+
.and_then(Value::as_str)
|
|
635
|
+
.filter(|name| !name.is_empty())
|
|
636
|
+
{
|
|
637
|
+
let session = crate::transport::SessionName::new(session);
|
|
638
|
+
if !candidates.iter().any(|existing| existing == &session) {
|
|
639
|
+
candidates.push(session);
|
|
665
640
|
}
|
|
666
|
-
return ShutdownSocketCleanup {
|
|
667
|
-
killed_sessions: sessions,
|
|
668
|
-
spared_sessions: Vec::new(),
|
|
669
|
-
error,
|
|
670
|
-
};
|
|
671
|
-
}
|
|
672
|
-
let targets = sessions
|
|
673
|
-
.iter()
|
|
674
|
-
.map(|session| session.as_str().to_string())
|
|
675
|
-
.collect::<Vec<_>>();
|
|
676
|
-
let error = match crate::kill_audit::pre_kill_audit(
|
|
677
|
-
event_log,
|
|
678
|
-
transport,
|
|
679
|
-
"shutdown.exclusive_socket",
|
|
680
|
-
crate::kill_audit::KILL_SERVER,
|
|
681
|
-
&targets,
|
|
682
|
-
) {
|
|
683
|
-
Ok(()) => transport.kill_server().err().map(|error| error.to_string()),
|
|
684
|
-
Err(error) => Some(format!("pre-kill audit failed: {error}")),
|
|
685
|
-
};
|
|
686
|
-
ShutdownSocketCleanup {
|
|
687
|
-
killed_sessions: sessions,
|
|
688
|
-
spared_sessions: Vec::new(),
|
|
689
|
-
error,
|
|
690
641
|
}
|
|
691
642
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
"killed_sessions": to_kill.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
700
|
-
}),
|
|
701
|
-
);
|
|
643
|
+
}
|
|
644
|
+
let targets = match transport.list_targets() {
|
|
645
|
+
Ok(targets) => targets,
|
|
646
|
+
Err(error) => {
|
|
647
|
+
return ShutdownSocketCleanup {
|
|
648
|
+
error: Some(format!("session ownership probe failed: {error}")),
|
|
649
|
+
..ShutdownSocketCleanup::default()
|
|
702
650
|
}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
651
|
+
}
|
|
652
|
+
};
|
|
653
|
+
let protected_sessions = anchor_sessions_from_state(
|
|
654
|
+
state,
|
|
655
|
+
transport.tmux_endpoint().as_deref(),
|
|
656
|
+
&targets,
|
|
657
|
+
event_log,
|
|
658
|
+
);
|
|
659
|
+
let mut result = ShutdownSocketCleanup::default();
|
|
660
|
+
for session in candidates {
|
|
661
|
+
if protected_sessions.contains(session.as_str()) {
|
|
662
|
+
push_unique_session(&mut result.spared_sessions, session);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
match session_ownership(workspace, state, transport, &session, &targets) {
|
|
666
|
+
SessionOwnership::Owned => {
|
|
667
|
+
let target = vec![session.as_str().to_string()];
|
|
668
|
+
if let Err(error) = crate::kill_audit::pre_kill_audit_scoped(
|
|
707
669
|
event_log,
|
|
708
670
|
transport,
|
|
709
|
-
"shutdown.
|
|
671
|
+
"shutdown.state_owned_session",
|
|
710
672
|
crate::kill_audit::KILL_SESSION,
|
|
711
|
-
&
|
|
673
|
+
&target,
|
|
674
|
+
workspace,
|
|
675
|
+
state_for_session(state, session.as_str())
|
|
676
|
+
.and_then(|identity| identity.get("team_key").and_then(Value::as_str)),
|
|
677
|
+
state_for_session(state, session.as_str()).and_then(state_generation),
|
|
678
|
+
"session_marker+state_session+pane_path",
|
|
679
|
+
"positive workspace/team ownership",
|
|
712
680
|
) {
|
|
713
|
-
error
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
if let Err(err) = transport.kill_session(session) {
|
|
718
|
-
if !tmux_absent_error(&err.to_string()) {
|
|
719
|
-
error.get_or_insert_with(|| err.to_string());
|
|
681
|
+
result.error.get_or_insert_with(|| format!("pre-kill audit failed: {error}"));
|
|
682
|
+
} else if let Err(error) = transport.kill_session(&session) {
|
|
683
|
+
if !tmux_absent_error(&error.to_string()) {
|
|
684
|
+
result.error.get_or_insert_with(|| error.to_string());
|
|
720
685
|
}
|
|
686
|
+
} else {
|
|
687
|
+
result.killed_sessions.push(session);
|
|
721
688
|
}
|
|
722
689
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
spared_sessions: spared,
|
|
726
|
-
error,
|
|
690
|
+
SessionOwnership::Foreign => {
|
|
691
|
+
push_unique_session(&mut result.spared_sessions, session);
|
|
727
692
|
}
|
|
693
|
+
SessionOwnership::Unknown => {
|
|
694
|
+
push_unique_session(&mut result.spared_sessions, session);
|
|
695
|
+
result.error.get_or_insert_with(|| "session ownership is unknown; destructive cleanup refused".to_string());
|
|
696
|
+
}
|
|
697
|
+
SessionOwnership::Gone => {}
|
|
728
698
|
}
|
|
729
699
|
}
|
|
700
|
+
result
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
704
|
+
enum SessionOwnership {
|
|
705
|
+
Owned,
|
|
706
|
+
Foreign,
|
|
707
|
+
Unknown,
|
|
708
|
+
Gone,
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
fn state_generation(state: &Value) -> Option<&str> {
|
|
712
|
+
state
|
|
713
|
+
.get("generation")
|
|
714
|
+
.and_then(Value::as_str)
|
|
715
|
+
.or_else(|| {
|
|
716
|
+
state
|
|
717
|
+
.get("agents")
|
|
718
|
+
.and_then(Value::as_object)
|
|
719
|
+
.and_then(|agents| {
|
|
720
|
+
agents.values().find_map(|agent| {
|
|
721
|
+
agent.get("spawned_at").and_then(Value::as_str)
|
|
722
|
+
})
|
|
723
|
+
})
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
fn state_for_session<'a>(state: &'a Value, session: &str) -> Option<&'a Value> {
|
|
728
|
+
if state.get("session_name").and_then(Value::as_str) == Some(session) {
|
|
729
|
+
return Some(state);
|
|
730
|
+
}
|
|
731
|
+
state
|
|
732
|
+
.get("teams")
|
|
733
|
+
.and_then(Value::as_object)
|
|
734
|
+
.and_then(|teams| teams.values().find_map(|team| state_for_session(team, session)))
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
fn session_ownership(
|
|
738
|
+
workspace: &Path,
|
|
739
|
+
state: &Value,
|
|
740
|
+
transport: &dyn crate::transport::Transport,
|
|
741
|
+
session: &crate::transport::SessionName,
|
|
742
|
+
targets: &[crate::transport::PaneInfo],
|
|
743
|
+
) -> SessionOwnership {
|
|
744
|
+
let identity_state = state_for_session(state, session.as_str()).unwrap_or(state);
|
|
745
|
+
let session_targets = targets
|
|
746
|
+
.iter()
|
|
747
|
+
.filter(|target| target.session.as_str() == session.as_str())
|
|
748
|
+
.collect::<Vec<_>>();
|
|
749
|
+
if session_targets.is_empty() {
|
|
750
|
+
return SessionOwnership::Gone;
|
|
751
|
+
}
|
|
752
|
+
let owner = match transport.session_owner(session) {
|
|
753
|
+
Ok(Some(owner)) => owner,
|
|
754
|
+
Ok(None) | Err(_) => return SessionOwnership::Unknown,
|
|
755
|
+
};
|
|
756
|
+
let canonical = workspace
|
|
757
|
+
.canonicalize()
|
|
758
|
+
.unwrap_or_else(|_| workspace.to_path_buf())
|
|
759
|
+
.to_string_lossy()
|
|
760
|
+
.to_string();
|
|
761
|
+
if owner.workspace != canonical {
|
|
762
|
+
return SessionOwnership::Foreign;
|
|
763
|
+
}
|
|
764
|
+
let Some(expected_team) = identity_state
|
|
765
|
+
.get("team_key")
|
|
766
|
+
.or_else(|| identity_state.get("active_team_key"))
|
|
767
|
+
.and_then(Value::as_str)
|
|
768
|
+
.filter(|team| !team.is_empty())
|
|
769
|
+
else {
|
|
770
|
+
return SessionOwnership::Unknown;
|
|
771
|
+
};
|
|
772
|
+
if owner.team != expected_team {
|
|
773
|
+
return SessionOwnership::Foreign;
|
|
774
|
+
}
|
|
775
|
+
let Some(expected_generation) = state_generation(identity_state)
|
|
776
|
+
.filter(|generation| !generation.is_empty()) else {
|
|
777
|
+
return SessionOwnership::Unknown;
|
|
778
|
+
};
|
|
779
|
+
if owner.generation != expected_generation {
|
|
780
|
+
return SessionOwnership::Foreign;
|
|
781
|
+
}
|
|
782
|
+
if session_targets.iter().any(|target| {
|
|
783
|
+
target.current_path.as_ref().map_or(true, |path| {
|
|
784
|
+
!path_is_under(path.as_path(), workspace)
|
|
785
|
+
})
|
|
786
|
+
}) {
|
|
787
|
+
return SessionOwnership::Unknown;
|
|
788
|
+
}
|
|
789
|
+
SessionOwnership::Owned
|
|
730
790
|
}
|
|
731
791
|
|
|
732
792
|
#[cfg(test)]
|
|
@@ -737,6 +797,43 @@ pub mod lifecycle_port {
|
|
|
737
797
|
use crate::transport::test_support::OfflineTransport;
|
|
738
798
|
use crate::transport::{PaneId, PaneInfo, SessionName};
|
|
739
799
|
|
|
800
|
+
#[test]
|
|
801
|
+
fn leader_process_protection_does_not_alias_panes_across_endpoints() {
|
|
802
|
+
let state = json!({
|
|
803
|
+
"team_owner": {"pane_id": "%2"},
|
|
804
|
+
"leader_receiver": {"pane_id": "%2", "tmux_socket": "/tmp/s4-host-only.sock"}
|
|
805
|
+
});
|
|
806
|
+
let table = [(10, 1), (20, 10), (30, 10), (31, 30)]
|
|
807
|
+
.into_iter()
|
|
808
|
+
.map(|(pid, ppid)| ProcessInfo { pid, ppid, pgid: None, session: None, command: String::new() })
|
|
809
|
+
.collect::<Vec<_>>();
|
|
810
|
+
for (endpoint, pane_pid, should_protect) in [
|
|
811
|
+
("/tmp/s4-worker-only.sock", 20, false),
|
|
812
|
+
("/tmp/s4-host-only.sock", 30, true),
|
|
813
|
+
] {
|
|
814
|
+
let transport = OfflineTransport::new()
|
|
815
|
+
.with_tmux_endpoint(endpoint)
|
|
816
|
+
.with_targets(vec![PaneInfo {
|
|
817
|
+
pane_id: PaneId::new("%2"),
|
|
818
|
+
session: SessionName::new("ordinary-session"),
|
|
819
|
+
window_index: None,
|
|
820
|
+
window_name: None,
|
|
821
|
+
pane_index: None,
|
|
822
|
+
tty: None,
|
|
823
|
+
current_command: None,
|
|
824
|
+
current_path: None,
|
|
825
|
+
active: true,
|
|
826
|
+
pane_pid: Some(pane_pid),
|
|
827
|
+
leader_env: BTreeMap::new(),
|
|
828
|
+
}]);
|
|
829
|
+
let mut protected = ShutdownProtection::default();
|
|
830
|
+
extend_protection_with_leader_panes(&mut protected, &transport, &state, &table);
|
|
831
|
+
assert_eq!(protected.pids.contains(&pane_pid), should_protect);
|
|
832
|
+
assert_eq!(protected.pids.contains(&31), should_protect, "leader descendants");
|
|
833
|
+
assert_eq!(protected.pids.contains(&10), should_protect, "leader's server");
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
740
837
|
#[test]
|
|
741
838
|
fn audit_write_failure_skips_destructive_kill() {
|
|
742
839
|
let root =
|
|
@@ -762,10 +859,12 @@ pub mod lifecycle_port {
|
|
|
762
859
|
leader_env: BTreeMap::new(),
|
|
763
860
|
}]);
|
|
764
861
|
let cleanup = bare_shutdown_socket_cleanup(
|
|
862
|
+
Path::new("/tmp/team-agent-test"),
|
|
765
863
|
&transport,
|
|
766
864
|
&json!({
|
|
767
865
|
"is_external_leader": true,
|
|
768
866
|
"tmux_socket_source": "leader_env",
|
|
867
|
+
"session_name": "team-audit-fail",
|
|
769
868
|
}),
|
|
770
869
|
&event_log,
|
|
771
870
|
);
|
|
@@ -777,7 +876,7 @@ pub mod lifecycle_port {
|
|
|
777
876
|
assert!(cleanup
|
|
778
877
|
.error
|
|
779
878
|
.as_deref()
|
|
780
|
-
.is_some_and(|error| error.contains("
|
|
879
|
+
.is_some_and(|error| error.contains("ownership is unknown")));
|
|
781
880
|
let _ = std::fs::remove_dir_all(root);
|
|
782
881
|
}
|
|
783
882
|
}
|
|
@@ -786,106 +885,6 @@ pub mod lifecycle_port {
|
|
|
786
885
|
crate::state::projection::state_is_external_leader(state)
|
|
787
886
|
}
|
|
788
887
|
|
|
789
|
-
fn managed_leader_socket_cleanup(
|
|
790
|
-
transport: &dyn crate::transport::Transport,
|
|
791
|
-
state: &Value,
|
|
792
|
-
sessions: &[crate::transport::SessionName],
|
|
793
|
-
event_log: &crate::event_log::EventLog,
|
|
794
|
-
) -> ShutdownSocketCleanup {
|
|
795
|
-
let target = state
|
|
796
|
-
.get("session_name")
|
|
797
|
-
.and_then(Value::as_str)
|
|
798
|
-
.filter(|session| !session.is_empty())
|
|
799
|
-
.map(crate::transport::SessionName::new);
|
|
800
|
-
// E49 (0.3.24 P0, shutdown kills leader CLI): for managed leader topology
|
|
801
|
-
// the target session may carry the leader anchor pane. The pre-fix code
|
|
802
|
-
// pushed it into `to_kill` and then issued `kill_session` — which ended
|
|
803
|
-
// the leader pane (= leader CLI). When the target session has a live
|
|
804
|
-
// anchor pane in `list_targets`, spare it instead.
|
|
805
|
-
let leader_anchor_ids = collect_state_leader_anchor_pane_ids(state);
|
|
806
|
-
let live_targets = transport.list_targets().unwrap_or_default();
|
|
807
|
-
let mut to_kill = Vec::new();
|
|
808
|
-
let mut target_spared_for_anchor: Option<crate::transport::SessionName> = None;
|
|
809
|
-
if let Some(target) = target {
|
|
810
|
-
let target_has_anchor = live_targets.iter().any(|t| {
|
|
811
|
-
t.session.as_str() == target.as_str()
|
|
812
|
-
&& leader_anchor_ids.contains(t.pane_id.as_str())
|
|
813
|
-
});
|
|
814
|
-
if target_has_anchor {
|
|
815
|
-
target_spared_for_anchor = Some(target);
|
|
816
|
-
} else if sessions.is_empty()
|
|
817
|
-
|| sessions
|
|
818
|
-
.iter()
|
|
819
|
-
.any(|session| session.as_str() == target.as_str())
|
|
820
|
-
{
|
|
821
|
-
to_kill.push(target);
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
let mut spared = sessions
|
|
825
|
-
.iter()
|
|
826
|
-
.filter(|session| {
|
|
827
|
-
!to_kill
|
|
828
|
-
.iter()
|
|
829
|
-
.any(|target| target.as_str() == session.as_str())
|
|
830
|
-
})
|
|
831
|
-
.cloned()
|
|
832
|
-
.collect::<Vec<_>>();
|
|
833
|
-
if let Some(anchored) = target_spared_for_anchor {
|
|
834
|
-
if !spared.iter().any(|s| s.as_str() == anchored.as_str()) {
|
|
835
|
-
spared.push(anchored);
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
let _ = event_log.write(
|
|
839
|
-
"shutdown.kill_server_skipped_managed_leader",
|
|
840
|
-
json!({
|
|
841
|
-
"reason": "managed_leader_topology",
|
|
842
|
-
"spared_sessions": spared.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
843
|
-
"killed_sessions": to_kill.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
844
|
-
}),
|
|
845
|
-
);
|
|
846
|
-
let mut error = None;
|
|
847
|
-
for session in &to_kill {
|
|
848
|
-
// 0.3.28 Step 6 (warn-only invariant): the kill list MUST NOT
|
|
849
|
-
// contain a leader session. `sessions_to_kill` already excludes
|
|
850
|
-
// leader-prefixed sessions, but the managed-leader cleanup path
|
|
851
|
-
// (this function) computes its own `to_kill` from `target`. Any
|
|
852
|
-
// leader-prefixed entry here is a topology violation introduced
|
|
853
|
-
// somewhere upstream. Log loudly and skip the kill.
|
|
854
|
-
if session
|
|
855
|
-
.as_str()
|
|
856
|
-
.starts_with(crate::leader::LEADER_SESSION_PREFIX)
|
|
857
|
-
{
|
|
858
|
-
eprintln!(
|
|
859
|
-
"team_agent::layout shutdown_invariant_violation kind=KillListContainsLeaderSession \
|
|
860
|
-
session=`{}` action=skipping_kill (post-Step-9 will hard-fail)",
|
|
861
|
-
session.as_str()
|
|
862
|
-
);
|
|
863
|
-
continue;
|
|
864
|
-
}
|
|
865
|
-
let targets = vec![session.as_str().to_string()];
|
|
866
|
-
if let Err(audit_error) = crate::kill_audit::pre_kill_audit(
|
|
867
|
-
event_log,
|
|
868
|
-
transport,
|
|
869
|
-
"shutdown.managed_worker_session",
|
|
870
|
-
crate::kill_audit::KILL_SESSION,
|
|
871
|
-
&targets,
|
|
872
|
-
) {
|
|
873
|
-
error.get_or_insert_with(|| format!("pre-kill audit failed: {audit_error}"));
|
|
874
|
-
continue;
|
|
875
|
-
}
|
|
876
|
-
if let Err(err) = transport.kill_session(session) {
|
|
877
|
-
if !tmux_absent_error(&err.to_string()) {
|
|
878
|
-
error.get_or_insert_with(|| err.to_string());
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
ShutdownSocketCleanup {
|
|
883
|
-
killed_sessions: to_kill,
|
|
884
|
-
spared_sessions: spared,
|
|
885
|
-
error,
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
|
|
889
888
|
fn owned_shutdown_endpoint(
|
|
890
889
|
workspace: &Path,
|
|
891
890
|
state: &Value,
|
|
@@ -970,40 +969,15 @@ pub mod lifecycle_port {
|
|
|
970
969
|
);
|
|
971
970
|
return cleanup;
|
|
972
971
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
crate::kill_audit::KILL_SERVER,
|
|
978
|
-
&[],
|
|
979
|
-
) {
|
|
980
|
-
cleanup.error = Some(format!("pre-kill audit failed: {error}"));
|
|
981
|
-
return cleanup;
|
|
982
|
-
}
|
|
983
|
-
if let Err(error) = transport.kill_server() {
|
|
984
|
-
cleanup.error = Some(error.to_string());
|
|
985
|
-
}
|
|
986
|
-
if let Some(path) = endpoint.socket_file.as_ref() {
|
|
987
|
-
if path.exists() {
|
|
988
|
-
match std::fs::remove_file(path) {
|
|
989
|
-
Ok(()) => cleanup.removed_files.push(path.display().to_string()),
|
|
990
|
-
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
991
|
-
Err(error) => {
|
|
992
|
-
cleanup.error.get_or_insert_with(|| error.to_string());
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
if path.exists() {
|
|
997
|
-
cleanup.residual_files.push(path.display().to_string());
|
|
998
|
-
}
|
|
999
|
-
}
|
|
972
|
+
// Endpoint ownership is not sufficient authority to tear down a tmux
|
|
973
|
+
// server: an endpoint can be shared after launch. Leave both the
|
|
974
|
+
// server and its socket file intact; only explicitly owned sessions
|
|
975
|
+
// are removed above.
|
|
1000
976
|
let _ = event_log.write(
|
|
1001
|
-
"shutdown.
|
|
977
|
+
"shutdown.owned_endpoint_cleanup_skipped",
|
|
1002
978
|
json!({
|
|
1003
979
|
"endpoint": endpoint.endpoint,
|
|
1004
|
-
"
|
|
1005
|
-
"residual_files": cleanup.residual_files.clone(),
|
|
1006
|
-
"error": cleanup.error.clone(),
|
|
980
|
+
"reason": "server_and_socket_cleanup_disabled_fail_closed",
|
|
1007
981
|
}),
|
|
1008
982
|
);
|
|
1009
983
|
cleanup
|
|
@@ -1081,10 +1055,18 @@ pub mod lifecycle_port {
|
|
|
1081
1055
|
root_pids.extend(pane_pids);
|
|
1082
1056
|
root_pids.sort_unstable();
|
|
1083
1057
|
root_pids.dedup();
|
|
1084
|
-
let root_pgids = process_pgids(&root_pids, &protected, &entry_table);
|
|
1085
1058
|
deadline.check("reap_process_tree")?;
|
|
1086
|
-
|
|
1087
|
-
|
|
1059
|
+
let process_event_log = crate::event_log::EventLog::new(&run_workspace);
|
|
1060
|
+
reap_process_tree(
|
|
1061
|
+
&run_workspace,
|
|
1062
|
+
&state,
|
|
1063
|
+
reap_scope,
|
|
1064
|
+
&root_pids,
|
|
1065
|
+
&protected,
|
|
1066
|
+
&entry_table,
|
|
1067
|
+
&process_event_log,
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1088
1070
|
let mut kill_error: Option<String> = None;
|
|
1089
1071
|
let mut killed_sessions = Vec::new();
|
|
1090
1072
|
let mut spared_sessions = Vec::new();
|
|
@@ -1100,57 +1082,114 @@ pub mod lifecycle_port {
|
|
|
1100
1082
|
// session per `list_targets`. When managed-leader, switch to per-pane
|
|
1101
1083
|
// cleanup: kill the workers individually and SPARE the leader's pane.
|
|
1102
1084
|
//
|
|
1103
|
-
// External
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
let leader_anchor_ids = collect_state_leader_anchor_pane_ids(
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1085
|
+
// External and managed leaders both pass the same positive ownership
|
|
1086
|
+
// gate; topology only decides whether a verified session is reduced
|
|
1087
|
+
// to worker panes or removed as a whole.
|
|
1088
|
+
let leader_anchor_ids = collect_state_leader_anchor_pane_ids(
|
|
1089
|
+
&state,
|
|
1090
|
+
transport.tmux_endpoint().as_deref(),
|
|
1091
|
+
);
|
|
1092
|
+
let (live_targets_now, live_targets_known) = match transport.list_targets() {
|
|
1093
|
+
Ok(targets) => (targets, true),
|
|
1094
|
+
Err(error) => {
|
|
1095
|
+
push_unique_session(&mut spared_sessions, session.clone());
|
|
1096
|
+
kill_error.get_or_insert_with(|| format!("session ownership probe failed: {error}"));
|
|
1097
|
+
(Vec::new(), false)
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1100
|
+
let ownership = if live_targets_known {
|
|
1101
|
+
session_ownership(
|
|
1102
|
+
&run_workspace,
|
|
1118
1103
|
&state,
|
|
1119
|
-
|
|
1120
|
-
|
|
1104
|
+
transport,
|
|
1105
|
+
session,
|
|
1121
1106
|
&live_targets_now,
|
|
1122
|
-
)
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1107
|
+
)
|
|
1108
|
+
} else {
|
|
1109
|
+
SessionOwnership::Unknown
|
|
1110
|
+
};
|
|
1111
|
+
if ownership != SessionOwnership::Owned {
|
|
1112
|
+
push_unique_session(&mut spared_sessions, session.clone());
|
|
1113
|
+
if ownership == SessionOwnership::Unknown {
|
|
1114
|
+
kill_error.get_or_insert_with(|| "session ownership is unknown; destructive cleanup refused".to_string());
|
|
1129
1115
|
}
|
|
1130
|
-
let _ =
|
|
1131
|
-
crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
|
|
1132
1116
|
} else {
|
|
1133
|
-
let
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
&
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1117
|
+
let session_has_leader_anchor = live_targets_now.iter().any(|target| {
|
|
1118
|
+
target.session.as_str() == session.as_str()
|
|
1119
|
+
&& leader_anchor_ids.contains(target.pane_id.as_str())
|
|
1120
|
+
});
|
|
1121
|
+
if crate::state::projection::state_is_managed_leader(&state)
|
|
1122
|
+
&& session_has_leader_anchor
|
|
1123
|
+
{
|
|
1124
|
+
let _ = event_log_write_session_spared(&run_workspace, session, &leader_anchor_ids);
|
|
1125
|
+
push_unique_session(&mut spared_sessions, session.clone());
|
|
1126
|
+
let worker_panes = collect_session_worker_panes(
|
|
1127
|
+
&state,
|
|
1128
|
+
session.as_str(),
|
|
1129
|
+
&leader_anchor_ids,
|
|
1130
|
+
&live_targets_now,
|
|
1131
|
+
);
|
|
1132
|
+
let event_log = crate::event_log::EventLog::new(&run_workspace);
|
|
1133
|
+
let identity_state = state_for_session(&state, session.as_str()).unwrap_or(&state);
|
|
1134
|
+
for pane in &worker_panes {
|
|
1135
|
+
let pane_target = vec![pane.as_str().to_string()];
|
|
1136
|
+
let audit = crate::kill_audit::pre_kill_audit_scoped(
|
|
1137
|
+
&event_log,
|
|
1138
|
+
transport,
|
|
1139
|
+
"shutdown.managed_worker_pane",
|
|
1140
|
+
"kill-pane",
|
|
1141
|
+
&pane_target,
|
|
1142
|
+
&run_workspace,
|
|
1143
|
+
identity_state.get("team_key").and_then(Value::as_str),
|
|
1144
|
+
state_generation(identity_state),
|
|
1145
|
+
"session_marker+state_worker_pane+pane_path",
|
|
1146
|
+
"positive workspace/team ownership",
|
|
1147
|
+
);
|
|
1148
|
+
if let Err(error) = audit {
|
|
1149
|
+
kill_error.get_or_insert_with(|| format!("pre-kill audit failed: {error}"));
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
if let Err(error) = transport.kill_pane(pane) {
|
|
1153
|
+
if !tmux_absent_error(&error.to_string()) {
|
|
1154
|
+
kill_error.get_or_insert_with(|| error.to_string());
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1148
1157
|
}
|
|
1158
|
+
let _ = crate::lifecycle::display::close_team_display_backends(
|
|
1159
|
+
&run_workspace,
|
|
1160
|
+
session,
|
|
1161
|
+
);
|
|
1149
1162
|
} else {
|
|
1150
|
-
|
|
1163
|
+
let targets = vec![session.as_str().to_string()];
|
|
1164
|
+
let event_log = crate::event_log::EventLog::new(&run_workspace);
|
|
1165
|
+
if let Err(error) = crate::kill_audit::pre_kill_audit_scoped(
|
|
1166
|
+
&event_log,
|
|
1167
|
+
transport,
|
|
1168
|
+
"shutdown.team_session",
|
|
1169
|
+
crate::kill_audit::KILL_SESSION,
|
|
1170
|
+
&targets,
|
|
1171
|
+
&run_workspace,
|
|
1172
|
+
state_for_session(&state, session.as_str())
|
|
1173
|
+
.and_then(|identity| identity.get("team_key").and_then(Value::as_str)),
|
|
1174
|
+
state_for_session(&state, session.as_str()).and_then(state_generation),
|
|
1175
|
+
"session_marker+state_session+pane_path",
|
|
1176
|
+
"positive workspace/team ownership",
|
|
1177
|
+
) {
|
|
1178
|
+
kill_error = Some(format!("pre-kill audit failed: {error}"));
|
|
1179
|
+
} else if let Err(error) = transport.kill_session(session) {
|
|
1180
|
+
if tmux_absent_error(&error.to_string()) {
|
|
1181
|
+
push_unique_session(&mut killed_sessions, session.clone());
|
|
1182
|
+
} else {
|
|
1183
|
+
kill_error = Some(error.to_string());
|
|
1184
|
+
}
|
|
1185
|
+
} else {
|
|
1186
|
+
push_unique_session(&mut killed_sessions, session.clone());
|
|
1187
|
+
}
|
|
1188
|
+
let _ = crate::lifecycle::display::close_team_display_backends(
|
|
1189
|
+
&run_workspace,
|
|
1190
|
+
session,
|
|
1191
|
+
);
|
|
1151
1192
|
}
|
|
1152
|
-
let _ =
|
|
1153
|
-
crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
|
|
1154
1193
|
}
|
|
1155
1194
|
}
|
|
1156
1195
|
deadline.check("reap_workspace_residuals")?;
|
|
@@ -1158,7 +1197,6 @@ pub mod lifecycle_port {
|
|
|
1158
1197
|
&run_workspace,
|
|
1159
1198
|
&state,
|
|
1160
1199
|
&root_pids,
|
|
1161
|
-
&root_pgids,
|
|
1162
1200
|
transport,
|
|
1163
1201
|
reap_scope,
|
|
1164
1202
|
&mut probe_degraded,
|
|
@@ -1166,7 +1204,7 @@ pub mod lifecycle_port {
|
|
|
1166
1204
|
if team.is_none() {
|
|
1167
1205
|
deadline.check("shared_socket_cleanup")?;
|
|
1168
1206
|
let event_log = crate::event_log::EventLog::new(&run_workspace);
|
|
1169
|
-
let cleanup = bare_shutdown_socket_cleanup(transport, &state, &event_log);
|
|
1207
|
+
let cleanup = bare_shutdown_socket_cleanup(&run_workspace, transport, &state, &event_log);
|
|
1170
1208
|
for session in cleanup.killed_sessions {
|
|
1171
1209
|
push_unique_session(&mut killed_sessions, session);
|
|
1172
1210
|
}
|
|
@@ -1175,6 +1213,7 @@ pub mod lifecycle_port {
|
|
|
1175
1213
|
kill_error.get_or_insert(error);
|
|
1176
1214
|
}
|
|
1177
1215
|
}
|
|
1216
|
+
spared_sessions.retain(|session| transport.has_session(session).unwrap_or(true));
|
|
1178
1217
|
deadline.check("session_residuals")?;
|
|
1179
1218
|
let (session_residuals, session_residual_error) = session_residuals_after_reap_many(
|
|
1180
1219
|
transport,
|
|
@@ -1208,7 +1247,6 @@ pub mod lifecycle_port {
|
|
|
1208
1247
|
&run_workspace,
|
|
1209
1248
|
&state,
|
|
1210
1249
|
&root_pids,
|
|
1211
|
-
&root_pgids,
|
|
1212
1250
|
&protected,
|
|
1213
1251
|
reap_scope,
|
|
1214
1252
|
&verify_table,
|
|
@@ -1838,12 +1876,25 @@ pub mod lifecycle_port {
|
|
|
1838
1876
|
/// pids; Gap 37 escalation order TERM -> grace -> KILL preserved), then a single
|
|
1839
1877
|
/// bounded wait for the whole union. kill/wait sets derive from the SAME snapshot
|
|
1840
1878
|
/// as the protected set (N39).
|
|
1841
|
-
fn reap_process_tree(
|
|
1879
|
+
fn reap_process_tree(
|
|
1880
|
+
workspace: &Path,
|
|
1881
|
+
state: &Value,
|
|
1882
|
+
scope: ShutdownReapScope,
|
|
1883
|
+
root_pids: &[u32],
|
|
1884
|
+
protected: &ShutdownProtection,
|
|
1885
|
+
table: &[ProcessInfo],
|
|
1886
|
+
event_log: &crate::event_log::EventLog,
|
|
1887
|
+
) {
|
|
1842
1888
|
let mut pids = Vec::new();
|
|
1843
1889
|
let mut seen = std::collections::BTreeSet::new();
|
|
1890
|
+
let spawn_cwds = state_spawn_cwds(state, scope);
|
|
1844
1891
|
for root in root_pids {
|
|
1845
1892
|
for pid in process_tree_from_table(*root, table) {
|
|
1846
|
-
|
|
1893
|
+
let owned = table
|
|
1894
|
+
.iter()
|
|
1895
|
+
.find(|process| process.pid == pid)
|
|
1896
|
+
.is_some_and(|process| process_matches_workspace(process, workspace, &spawn_cwds));
|
|
1897
|
+
if owned && !protected.contains_pid(pid) && seen.insert(pid) {
|
|
1847
1898
|
pids.push(pid);
|
|
1848
1899
|
}
|
|
1849
1900
|
}
|
|
@@ -1851,50 +1902,58 @@ pub mod lifecycle_port {
|
|
|
1851
1902
|
if pids.is_empty() {
|
|
1852
1903
|
return;
|
|
1853
1904
|
}
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1905
|
+
let audit_targets = pids.iter().map(u32::to_string).collect::<Vec<_>>();
|
|
1906
|
+
if event_log
|
|
1907
|
+
.write(
|
|
1908
|
+
"transport.pre_kill_audit",
|
|
1909
|
+
json!({
|
|
1910
|
+
"phase": "pre_call",
|
|
1911
|
+
"caller": "shutdown.process_tree",
|
|
1912
|
+
"caller_pid": std::process::id(),
|
|
1913
|
+
"action": "signal-pid",
|
|
1914
|
+
"endpoint": Value::Null,
|
|
1915
|
+
"targets": audit_targets,
|
|
1916
|
+
"workspace": workspace.to_string_lossy(),
|
|
1917
|
+
"team": state.get("team_key").and_then(Value::as_str),
|
|
1918
|
+
"generation": state_generation(state),
|
|
1919
|
+
"owner_evidence": "argv+cwd+state_pid_tree",
|
|
1920
|
+
"reason": "positive per-pid process ownership",
|
|
1921
|
+
}),
|
|
1922
|
+
)
|
|
1923
|
+
.is_err()
|
|
1924
|
+
{
|
|
1925
|
+
return;
|
|
1859
1926
|
}
|
|
1860
|
-
std::thread::sleep(std::time::Duration::from_millis(150));
|
|
1861
1927
|
for pid in pids.iter().rev() {
|
|
1862
1928
|
let _ = crate::platform::process::terminate_pid(
|
|
1863
1929
|
*pid,
|
|
1864
|
-
crate::platform::process::SignalKind::TerminateForce,
|
|
1865
|
-
);
|
|
1866
|
-
}
|
|
1867
|
-
wait_for_processes_gone(&pids, std::time::Duration::from_secs(1));
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
fn reap_process_groups(pgids: &[u32], protected: &ShutdownProtection) {
|
|
1871
|
-
// 0.5.x Windows portability Batch 3: routes group termination
|
|
1872
|
-
// through `platform::process::terminate_group`. Unix keeps
|
|
1873
|
-
// `kill(-pgid, SIGTERM|SIGKILL)` semantics byte-for-byte;
|
|
1874
|
-
// Windows returns AlreadyGone (no pgid concept — Job Object
|
|
1875
|
-
// teardown is the shim-side concern per design §Route B).
|
|
1876
|
-
// The `pgid_t <= 1 || protected.contains_pgid(...)` filter
|
|
1877
|
-
// stays in place so pgid 0/1 (kernel/init) and the caller's
|
|
1878
|
-
// own group are never targeted.
|
|
1879
|
-
for pgid in pgids {
|
|
1880
|
-
if *pgid <= 1 || protected.contains_pgid(*pgid) {
|
|
1881
|
-
continue;
|
|
1882
|
-
}
|
|
1883
|
-
let _ = crate::platform::process::terminate_group(
|
|
1884
|
-
*pgid,
|
|
1885
1930
|
crate::platform::process::SignalKind::TerminateGraceful,
|
|
1886
1931
|
);
|
|
1887
1932
|
}
|
|
1888
1933
|
std::thread::sleep(std::time::Duration::from_millis(150));
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1934
|
+
// Re-probe identity before escalation. If the probe fails, refuse the
|
|
1935
|
+
// force signal rather than risking a recycled PID.
|
|
1936
|
+
if let Ok(live_table) = probed_process_table() {
|
|
1937
|
+
let spawn_cwds = state_spawn_cwds(state, scope);
|
|
1938
|
+
for pid in pids.iter().rev() {
|
|
1939
|
+
let Some(before) = table.iter().find(|process| process.pid == *pid) else {
|
|
1940
|
+
continue;
|
|
1941
|
+
};
|
|
1942
|
+
let Some(after) = live_table.iter().find(|process| process.pid == *pid) else {
|
|
1943
|
+
continue;
|
|
1944
|
+
};
|
|
1945
|
+
if before.ppid == after.ppid
|
|
1946
|
+
&& before.command == after.command
|
|
1947
|
+
&& process_matches_workspace(after, workspace, &spawn_cwds)
|
|
1948
|
+
{
|
|
1949
|
+
let _ = crate::platform::process::terminate_pid(
|
|
1950
|
+
*pid,
|
|
1951
|
+
crate::platform::process::SignalKind::TerminateForce,
|
|
1952
|
+
);
|
|
1953
|
+
}
|
|
1892
1954
|
}
|
|
1893
|
-
let _ = crate::platform::process::terminate_group(
|
|
1894
|
-
*pgid,
|
|
1895
|
-
crate::platform::process::SignalKind::TerminateForce,
|
|
1896
|
-
);
|
|
1897
1955
|
}
|
|
1956
|
+
wait_for_processes_gone(&pids, std::time::Duration::from_secs(1));
|
|
1898
1957
|
}
|
|
1899
1958
|
|
|
1900
1959
|
/// PERF-6 C-①-2 + C-②-5: every residual round fetches ONE fresh snapshot (reap
|
|
@@ -1904,7 +1963,6 @@ pub mod lifecycle_port {
|
|
|
1904
1963
|
workspace: &Path,
|
|
1905
1964
|
state: &Value,
|
|
1906
1965
|
root_pids: &[u32],
|
|
1907
|
-
root_pgids: &[u32],
|
|
1908
1966
|
transport: &dyn crate::transport::Transport,
|
|
1909
1967
|
scope: ShutdownReapScope,
|
|
1910
1968
|
probe_degraded: &mut bool,
|
|
@@ -1917,7 +1975,6 @@ pub mod lifecycle_port {
|
|
|
1917
1975
|
workspace,
|
|
1918
1976
|
state,
|
|
1919
1977
|
root_pids,
|
|
1920
|
-
root_pgids,
|
|
1921
1978
|
&protected,
|
|
1922
1979
|
scope,
|
|
1923
1980
|
&round_table,
|
|
@@ -1929,12 +1986,15 @@ pub mod lifecycle_port {
|
|
|
1929
1986
|
.iter()
|
|
1930
1987
|
.map(|process| process.pid)
|
|
1931
1988
|
.collect::<Vec<_>>();
|
|
1932
|
-
reap_process_tree(
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1989
|
+
reap_process_tree(
|
|
1990
|
+
workspace,
|
|
1991
|
+
state,
|
|
1992
|
+
scope,
|
|
1993
|
+
&residual_pids,
|
|
1994
|
+
&protected,
|
|
1995
|
+
&round_table,
|
|
1996
|
+
&crate::event_log::EventLog::new(workspace),
|
|
1997
|
+
);
|
|
1938
1998
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
1939
1999
|
}
|
|
1940
2000
|
}
|
|
@@ -2045,12 +2105,13 @@ pub mod lifecycle_port {
|
|
|
2045
2105
|
/// → 任一 team 的 shutdown 都不杀任何 team 的 leader 锚 pane
|
|
2046
2106
|
pub fn collect_state_leader_anchor_pane_ids(
|
|
2047
2107
|
state: &Value,
|
|
2108
|
+
endpoint: Option<&str>,
|
|
2048
2109
|
) -> std::collections::BTreeSet<String> {
|
|
2049
2110
|
let mut out = std::collections::BTreeSet::new();
|
|
2050
|
-
push_anchor_pane_id(state, &mut out);
|
|
2111
|
+
push_anchor_pane_id(state, endpoint, &mut out);
|
|
2051
2112
|
if let Some(teams) = state.get("teams").and_then(Value::as_object) {
|
|
2052
2113
|
for (_, team_state) in teams {
|
|
2053
|
-
push_anchor_pane_id(team_state, &mut out);
|
|
2114
|
+
push_anchor_pane_id(team_state, endpoint, &mut out);
|
|
2054
2115
|
}
|
|
2055
2116
|
}
|
|
2056
2117
|
out
|
|
@@ -2116,9 +2177,9 @@ pub mod lifecycle_port {
|
|
|
2116
2177
|
if leader_anchor_ids.contains(pane_id) {
|
|
2117
2178
|
continue;
|
|
2118
2179
|
}
|
|
2119
|
-
let in_live = live_targets
|
|
2120
|
-
.
|
|
2121
|
-
|
|
2180
|
+
let in_live = live_targets.iter().any(|info| {
|
|
2181
|
+
info.pane_id.as_str() == pane_id && info.session.as_str() == session
|
|
2182
|
+
});
|
|
2122
2183
|
if in_live && seen.insert(pane_id.to_string()) {
|
|
2123
2184
|
out.push(crate::transport::PaneId::new(pane_id));
|
|
2124
2185
|
continue;
|
|
@@ -2170,17 +2231,40 @@ pub mod lifecycle_port {
|
|
|
2170
2231
|
Ok(())
|
|
2171
2232
|
}
|
|
2172
2233
|
|
|
2173
|
-
///
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2234
|
+
/// Pane IDs are server-local. Exclude proven foreign-endpoint anchors, but
|
|
2235
|
+
/// retain legacy anchors when their endpoint (or the transport's) is unknown.
|
|
2236
|
+
fn push_anchor_pane_id(
|
|
2237
|
+
state: &Value,
|
|
2238
|
+
endpoint: Option<&str>,
|
|
2239
|
+
out: &mut std::collections::BTreeSet<String>,
|
|
2240
|
+
) {
|
|
2241
|
+
for (key, peer_key) in [("team_owner", "leader_receiver"), ("leader_receiver", "team_owner")] {
|
|
2242
|
+
let Some(anchor) = state.get(key) else { continue };
|
|
2243
|
+
let Some(pane_id) = anchor.get("pane_id").and_then(Value::as_str).filter(|s| !s.is_empty()) else {
|
|
2244
|
+
continue;
|
|
2245
|
+
};
|
|
2246
|
+
// team_owner often records only a pane ID; its receiver supplies
|
|
2247
|
+
// the endpoint only when it describes that exact same anchor.
|
|
2248
|
+
let anchor_endpoint = anchor.get("tmux_socket").and_then(Value::as_str)
|
|
2180
2249
|
.filter(|s| !s.is_empty())
|
|
2181
|
-
|
|
2182
|
-
|
|
2250
|
+
.or_else(|| {
|
|
2251
|
+
state.get(peer_key)
|
|
2252
|
+
.filter(|peer| peer.get("pane_id").and_then(Value::as_str) == Some(pane_id))
|
|
2253
|
+
.and_then(|peer| peer.get("tmux_socket"))
|
|
2254
|
+
.and_then(Value::as_str)
|
|
2255
|
+
.filter(|s| !s.is_empty())
|
|
2256
|
+
});
|
|
2257
|
+
if let (Some(anchor_path), Some(transport_path)) = (
|
|
2258
|
+
anchor_endpoint.and_then(socket_file_for_endpoint),
|
|
2259
|
+
endpoint.filter(|s| !s.is_empty()).and_then(socket_file_for_endpoint),
|
|
2260
|
+
) {
|
|
2261
|
+
let anchor_path = anchor_path.canonicalize().unwrap_or(anchor_path);
|
|
2262
|
+
let transport_path = transport_path.canonicalize().unwrap_or(transport_path);
|
|
2263
|
+
if anchor_path != transport_path {
|
|
2264
|
+
continue;
|
|
2265
|
+
}
|
|
2183
2266
|
}
|
|
2267
|
+
out.insert(pane_id.to_string());
|
|
2184
2268
|
}
|
|
2185
2269
|
}
|
|
2186
2270
|
|
|
@@ -2291,8 +2375,10 @@ pub mod lifecycle_port {
|
|
|
2291
2375
|
);
|
|
2292
2376
|
// Source 2: state.json team_owner / leader_receiver 真锚 pane_id(top-level +
|
|
2293
2377
|
// teams[*]),per-workspace socket 命中。
|
|
2294
|
-
let anchor_pane_ids
|
|
2295
|
-
|
|
2378
|
+
let anchor_pane_ids = collect_state_leader_anchor_pane_ids(
|
|
2379
|
+
state,
|
|
2380
|
+
transport.tmux_endpoint().as_deref(),
|
|
2381
|
+
);
|
|
2296
2382
|
leader_pane_pids.extend(
|
|
2297
2383
|
pane_targets
|
|
2298
2384
|
.iter()
|
|
@@ -2308,6 +2394,7 @@ pub mod lifecycle_port {
|
|
|
2308
2394
|
// 的 list_targets 找 anchor pane_id → pane_pid → 进入 process_tree 保护。
|
|
2309
2395
|
// 不在 state 中的 socket 不查(MUST-17 不撒宽 / 不主动枚举全机器 sockets)。
|
|
2310
2396
|
for socket_endpoint in collect_state_recorded_tmux_sockets(state) {
|
|
2397
|
+
let anchor_pane_ids = collect_state_leader_anchor_pane_ids(state, Some(&socket_endpoint));
|
|
2311
2398
|
let cross_backend =
|
|
2312
2399
|
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&socket_endpoint);
|
|
2313
2400
|
let cross_panes =
|
|
@@ -2386,44 +2473,16 @@ pub mod lifecycle_port {
|
|
|
2386
2473
|
}
|
|
2387
2474
|
}
|
|
2388
2475
|
|
|
2389
|
-
fn process_pgids(
|
|
2390
|
-
pids: &[u32],
|
|
2391
|
-
protected: &ShutdownProtection,
|
|
2392
|
-
table: &[ProcessInfo],
|
|
2393
|
-
) -> Vec<u32> {
|
|
2394
|
-
let mut pgids = pids
|
|
2395
|
-
.iter()
|
|
2396
|
-
.filter_map(|pid| table.iter().find(|process| process.pid == *pid))
|
|
2397
|
-
.filter_map(|process| process.pgid)
|
|
2398
|
-
.filter(|pgid| {
|
|
2399
|
-
// 0.5.x Windows portability Batch 4: `libc::pid_t` was
|
|
2400
|
-
// used here as a signed-integer conversion gate to
|
|
2401
|
-
// reject values > INT_MAX. Replace with the equivalent
|
|
2402
|
-
// `i32::try_from` (pgid_t is `c_int` on every Unix we
|
|
2403
|
-
// support). Windows has no pgid concept so `pgids`
|
|
2404
|
-
// is empty in practice — the filter is dead code on
|
|
2405
|
-
// Windows but must still compile.
|
|
2406
|
-
i32::try_from(*pgid)
|
|
2407
|
-
.map(|pgid_int| pgid_int > 1 && !protected.contains_pgid(*pgid))
|
|
2408
|
-
.unwrap_or(false)
|
|
2409
|
-
})
|
|
2410
|
-
.collect::<Vec<_>>();
|
|
2411
|
-
pgids.sort_unstable();
|
|
2412
|
-
pgids.dedup();
|
|
2413
|
-
pgids
|
|
2414
|
-
}
|
|
2415
|
-
|
|
2416
2476
|
fn process_residuals(
|
|
2417
2477
|
workspace: &Path,
|
|
2418
2478
|
state: &Value,
|
|
2419
2479
|
root_pids: &[u32],
|
|
2420
|
-
root_pgids: &[u32],
|
|
2421
2480
|
protected: &ShutdownProtection,
|
|
2422
2481
|
scope: ShutdownReapScope,
|
|
2423
2482
|
table: &[ProcessInfo],
|
|
2424
2483
|
) -> Vec<Value> {
|
|
2425
2484
|
let mut residuals = matched_processes(
|
|
2426
|
-
workspace, state, root_pids,
|
|
2485
|
+
workspace, state, root_pids, protected, scope, table,
|
|
2427
2486
|
);
|
|
2428
2487
|
let mut seen = residuals
|
|
2429
2488
|
.iter()
|
|
@@ -2461,7 +2520,6 @@ pub mod lifecycle_port {
|
|
|
2461
2520
|
workspace: &Path,
|
|
2462
2521
|
state: &Value,
|
|
2463
2522
|
root_pids: &[u32],
|
|
2464
|
-
root_pgids: &[u32],
|
|
2465
2523
|
protected: &ShutdownProtection,
|
|
2466
2524
|
scope: ShutdownReapScope,
|
|
2467
2525
|
table: &[ProcessInfo],
|
|
@@ -2471,33 +2529,14 @@ pub mod lifecycle_port {
|
|
|
2471
2529
|
.flat_map(|pid| process_tree_from_table(*pid, table))
|
|
2472
2530
|
.filter(|pid| !protected.contains_pid(*pid))
|
|
2473
2531
|
.collect::<std::collections::BTreeSet<_>>();
|
|
2474
|
-
let root_pgids = root_pgids
|
|
2475
|
-
.iter()
|
|
2476
|
-
.copied()
|
|
2477
|
-
.collect::<std::collections::BTreeSet<_>>();
|
|
2478
2532
|
let spawn_cwds = state_spawn_cwds(state, scope);
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
let matches_workspace = scope == ShutdownReapScope::Workspace
|
|
2487
|
-
&& process_matches_workspace(
|
|
2488
|
-
process,
|
|
2489
|
-
&workspace_text,
|
|
2490
|
-
&spawn_cwds,
|
|
2491
|
-
&mut cwd_probe_budget,
|
|
2492
|
-
);
|
|
2493
|
-
if matches_workspace
|
|
2494
|
-
|| root_tree.contains(&process.pid)
|
|
2495
|
-
|| process.pgid.is_some_and(|pgid| root_pgids.contains(&pgid))
|
|
2496
|
-
{
|
|
2497
|
-
out.push(process.clone());
|
|
2498
|
-
}
|
|
2499
|
-
}
|
|
2500
|
-
out
|
|
2533
|
+
table
|
|
2534
|
+
.iter()
|
|
2535
|
+
.filter(|process| !protected.contains_pid(process.pid))
|
|
2536
|
+
.filter(|process| root_tree.contains(&process.pid))
|
|
2537
|
+
.filter(|process| process_matches_workspace(process, workspace, &spawn_cwds))
|
|
2538
|
+
.cloned()
|
|
2539
|
+
.collect()
|
|
2501
2540
|
}
|
|
2502
2541
|
|
|
2503
2542
|
fn process_tree_from_table(root_pid: u32, table: &[ProcessInfo]) -> Vec<u32> {
|
|
@@ -2550,59 +2589,21 @@ pub mod lifecycle_port {
|
|
|
2550
2589
|
|
|
2551
2590
|
fn process_matches_workspace(
|
|
2552
2591
|
process: &ProcessInfo,
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
cwd_probe_budget: &mut usize,
|
|
2592
|
+
workspace: &Path,
|
|
2593
|
+
_spawn_cwds: &[PathBuf],
|
|
2556
2594
|
) -> bool {
|
|
2557
|
-
let
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
}
|
|
2570
|
-
*cwd_probe_budget -= 1;
|
|
2571
|
-
let Some(cwd) = process_cwd(process.pid) else {
|
|
2572
|
-
return false;
|
|
2573
|
-
};
|
|
2574
|
-
spawn_cwds
|
|
2575
|
-
.iter()
|
|
2576
|
-
.any(|spawn_cwd| path_is_under(&cwd, spawn_cwd))
|
|
2577
|
-
}
|
|
2578
|
-
|
|
2579
|
-
fn process_cwd(pid: u32) -> Option<PathBuf> {
|
|
2580
|
-
let proc_cwd = PathBuf::from(format!("/proc/{pid}/cwd"));
|
|
2581
|
-
if let Ok(path) = std::fs::read_link(proc_cwd) {
|
|
2582
|
-
return Some(path);
|
|
2583
|
-
}
|
|
2584
|
-
if crate::os_probe::probe_timed_out() {
|
|
2585
|
-
return None;
|
|
2586
|
-
}
|
|
2587
|
-
let output = crate::os_probe::bounded_command_output_with_probe(
|
|
2588
|
-
std::process::Command::new("lsof").args([
|
|
2589
|
-
"-a",
|
|
2590
|
-
"-p",
|
|
2591
|
-
&pid.to_string(),
|
|
2592
|
-
"-d",
|
|
2593
|
-
"cwd",
|
|
2594
|
-
"-Fn",
|
|
2595
|
-
]),
|
|
2596
|
-
"lsof_cwd",
|
|
2597
|
-
Some(pid),
|
|
2598
|
-
)
|
|
2599
|
-
.ok()?;
|
|
2600
|
-
if !output.status.success() {
|
|
2601
|
-
return None;
|
|
2602
|
-
}
|
|
2603
|
-
String::from_utf8_lossy(&output.stdout)
|
|
2604
|
-
.lines()
|
|
2605
|
-
.find_map(|line| line.strip_prefix('n').map(PathBuf::from))
|
|
2595
|
+
let workspace = workspace
|
|
2596
|
+
.canonicalize()
|
|
2597
|
+
.unwrap_or_else(|_| workspace.to_path_buf());
|
|
2598
|
+
crate::platform::argv::argv_tokens(process.pid).is_some_and(|tokens| {
|
|
2599
|
+
tokens.windows(2).any(|pair| {
|
|
2600
|
+
pair[0] == "--workspace"
|
|
2601
|
+
&& Path::new(&pair[1])
|
|
2602
|
+
.canonicalize()
|
|
2603
|
+
.map(|path| path == workspace)
|
|
2604
|
+
.unwrap_or(false)
|
|
2605
|
+
})
|
|
2606
|
+
})
|
|
2606
2607
|
}
|
|
2607
2608
|
|
|
2608
2609
|
fn path_is_under(path: &Path, root: &Path) -> bool {
|
|
@@ -4488,6 +4489,7 @@ pub mod lifecycle_port {
|
|
|
4488
4489
|
match status {
|
|
4489
4490
|
crate::coordinator::StopOutcome::Missing => "missing",
|
|
4490
4491
|
crate::coordinator::StopOutcome::InvalidPidRemoved => "invalid_pid_removed",
|
|
4492
|
+
crate::coordinator::StopOutcome::NotOwned => "not_owned",
|
|
4491
4493
|
crate::coordinator::StopOutcome::KillFailed => "kill_failed",
|
|
4492
4494
|
crate::coordinator::StopOutcome::Stopped => "stopped",
|
|
4493
4495
|
}
|
|
@@ -4823,17 +4825,15 @@ pub mod diagnose_port {
|
|
|
4823
4825
|
&& profile_smoke_value.get("status").and_then(Value::as_str)
|
|
4824
4826
|
== Some("legacy_team_invalid");
|
|
4825
4827
|
let effective_smoke_ok = profile_smoke_ok || legacy_only_failure;
|
|
4828
|
+
// Coordinator and runtime topology are facts of the unified pipeline,
|
|
4829
|
+
// not of the static environment probe. The caller supplies the
|
|
4830
|
+
// selected runtime workspace and performs one read-only health read.
|
|
4826
4831
|
let ok = workspace_valid && (team_context || workspace_has_entries) && effective_smoke_ok;
|
|
4827
|
-
let health = crate::coordinator::coordinator_health(
|
|
4828
|
-
&crate::coordinator::WorkspacePath::new(workspace.to_path_buf()),
|
|
4829
|
-
);
|
|
4830
|
-
let coordinator_ok = health.ok;
|
|
4831
|
-
let coordinator_error = health.metadata_mismatch_reason.clone();
|
|
4832
4832
|
let state = crate::state::persist::load_runtime_state(workspace).unwrap_or(Value::Null);
|
|
4833
4833
|
let grok_slot = crate::cli::grok_slot_report(workspace, &state).to_json();
|
|
4834
4834
|
let grok_slot_ok = grok_slot.get("consistent").and_then(Value::as_bool) == Some(true)
|
|
4835
4835
|
&& grok_slot.get("readable").and_then(Value::as_bool) == Some(true);
|
|
4836
|
-
let ok = ok && grok_slot_ok
|
|
4836
|
+
let ok = ok && grok_slot_ok;
|
|
4837
4837
|
Ok(json!({
|
|
4838
4838
|
"tmux": {
|
|
4839
4839
|
"installed": tmux_installed,
|
|
@@ -4841,14 +4841,14 @@ pub mod diagnose_port {
|
|
|
4841
4841
|
},
|
|
4842
4842
|
"workspace": workspace.to_string_lossy().to_string(),
|
|
4843
4843
|
"workspace_is_git_repo": workspace.join(".git").exists(),
|
|
4844
|
-
"providers":
|
|
4844
|
+
"providers": crate::cli::diagnose::provider_doctor_checks(),
|
|
4845
4845
|
"mcp": {
|
|
4846
4846
|
"server_command": which_path("team_orchestrator"),
|
|
4847
4847
|
"local_module": true,
|
|
4848
4848
|
},
|
|
4849
4849
|
"secret_scan": secret_scan(workspace),
|
|
4850
4850
|
"profile_smoke": profile_smoke_value,
|
|
4851
|
-
"coordinator":
|
|
4851
|
+
"coordinator": coordinator_not_required_value(),
|
|
4852
4852
|
"grok_slot": grok_slot,
|
|
4853
4853
|
"ok": ok,
|
|
4854
4854
|
"error": if ok {
|
|
@@ -4860,10 +4860,6 @@ pub mod diagnose_port {
|
|
|
4860
4860
|
.unwrap_or_else(|| json!("grok_slot_mismatch"))
|
|
4861
4861
|
} else if !profile_smoke_ok && !legacy_only_failure {
|
|
4862
4862
|
json!("profile_smoke_failed")
|
|
4863
|
-
} else if !coordinator_ok {
|
|
4864
|
-
coordinator_error
|
|
4865
|
-
.map(Value::String)
|
|
4866
|
-
.unwrap_or_else(|| json!("coordinator_unavailable"))
|
|
4867
4863
|
} else if workspace_valid {
|
|
4868
4864
|
json!("workspace has no Team Agent spec or runtime context")
|
|
4869
4865
|
} else {
|
|
@@ -5055,11 +5051,12 @@ pub mod diagnose_port {
|
|
|
5055
5051
|
continue;
|
|
5056
5052
|
};
|
|
5057
5053
|
let rel = path.strip_prefix(root).unwrap_or(path);
|
|
5054
|
+
// Keep only a locating rule/path/line tuple. Never include even
|
|
5055
|
+
// redacted assignment text in the structured report.
|
|
5058
5056
|
findings.push(json!({
|
|
5059
5057
|
"path": rel.to_string_lossy().to_string(),
|
|
5060
5058
|
"line": idx.saturating_add(1),
|
|
5061
5059
|
"rule": "api_key_assignment",
|
|
5062
|
-
"match_excerpt": format!("{key}=<redacted>"),
|
|
5063
5060
|
}));
|
|
5064
5061
|
}
|
|
5065
5062
|
}
|
|
@@ -5224,7 +5221,23 @@ pub mod diagnose_port {
|
|
|
5224
5221
|
})
|
|
5225
5222
|
}
|
|
5226
5223
|
|
|
5227
|
-
fn
|
|
5224
|
+
pub(crate) fn coordinator_not_required_value() -> Value {
|
|
5225
|
+
json!({
|
|
5226
|
+
"ok": true,
|
|
5227
|
+
"status": "not_required",
|
|
5228
|
+
"pid": Value::Null,
|
|
5229
|
+
"metadata": Value::Null,
|
|
5230
|
+
"metadata_ok": true,
|
|
5231
|
+
"metadata_mismatch_reason": Value::Null,
|
|
5232
|
+
"binary_path": Value::Null,
|
|
5233
|
+
"binary_version": Value::Null,
|
|
5234
|
+
"schema_ok": true,
|
|
5235
|
+
"schema_error": Value::Null,
|
|
5236
|
+
"schema": {"message_store_schema_version": Value::Null},
|
|
5237
|
+
})
|
|
5238
|
+
}
|
|
5239
|
+
|
|
5240
|
+
pub(crate) fn coordinator_health_value(health: crate::coordinator::HealthReport) -> Value {
|
|
5228
5241
|
let expose_binary_drift = health.service_available && !health.metadata_ok;
|
|
5229
5242
|
let binary_identity_relation = health.binary_identity_relation.as_str();
|
|
5230
5243
|
let mut value = json!({
|