@team-agent/installer 0.5.91 → 0.5.94
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 +43 -10
- package/crates/team-agent/src/cli/diagnose.rs +78 -39
- package/crates/team-agent/src/cli/mod.rs +407 -450
- package/crates/team-agent/src/cli/models.rs +1 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +154 -30
- package/crates/team-agent/src/cli/triage.rs +183 -0
- package/crates/team-agent/src/coordinator/health.rs +133 -31
- 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/db/migration.rs +36 -15
- 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/tests/launch_spawn.rs +10 -7
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +91 -4
- package/crates/team-agent/src/platform/process.rs +4 -45
- package/crates/team-agent/src/redaction.rs +5 -1
- package/crates/team-agent/src/tmux_backend.rs +113 -3
- package/crates/team-agent/src/topology.rs +17 -11
- 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
|
@@ -66,6 +66,7 @@ pub mod profile;
|
|
|
66
66
|
pub mod send;
|
|
67
67
|
pub(crate) mod spec;
|
|
68
68
|
pub mod status;
|
|
69
|
+
pub(crate) mod triage;
|
|
69
70
|
pub mod types;
|
|
70
71
|
|
|
71
72
|
pub use adapters::*;
|
|
@@ -548,9 +549,7 @@ pub mod lifecycle_port {
|
|
|
548
549
|
/// E12 下沉纯函数:bare-shutdown socket 拆除决策。
|
|
549
550
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
550
551
|
pub(crate) enum KillDecision {
|
|
551
|
-
///
|
|
552
|
-
KillServerExclusive,
|
|
553
|
-
/// 有 spare(leader 锚/前缀)或非独享 → 逐 session kill,绝不 kill-server。
|
|
552
|
+
/// Ownership-gated cleanup always uses per-session kills; never whole-server teardown.
|
|
554
553
|
KillIndividually {
|
|
555
554
|
to_kill: Vec<crate::transport::SessionName>,
|
|
556
555
|
spared: Vec<crate::transport::SessionName>,
|
|
@@ -559,8 +558,7 @@ pub mod lifecycle_port {
|
|
|
559
558
|
|
|
560
559
|
///
|
|
561
560
|
/// E12 纯决策(单测下沉):spare = `anchor_sessions` ∪ `team-agent-leader-*` 前缀(并集,锚优先)。
|
|
562
|
-
///
|
|
563
|
-
/// kill 非 spare 的(共享 socket / leader 在 → 绝不整 server 拆)。空 session 集 → 逐 kill(no-op)。
|
|
561
|
+
/// Always returns a per-session plan; ownership markers are checked by the caller.
|
|
564
562
|
pub(crate) fn sessions_to_kill(
|
|
565
563
|
sessions: &[crate::transport::SessionName],
|
|
566
564
|
anchor_sessions: &std::collections::BTreeSet<String>,
|
|
@@ -571,12 +569,9 @@ pub mod lifecycle_port {
|
|
|
571
569
|
};
|
|
572
570
|
let spared: Vec<_> = sessions.iter().filter(|s| is_spared(s)).cloned().collect();
|
|
573
571
|
let to_kill: Vec<_> = sessions.iter().filter(|s| !is_spared(s)).cloned().collect();
|
|
574
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
} else {
|
|
578
|
-
KillDecision::KillIndividually { to_kill, spared }
|
|
579
|
-
}
|
|
572
|
+
// Fail closed: even an apparently exclusive socket is not proof of
|
|
573
|
+
// ownership, so whole-server teardown is never selected.
|
|
574
|
+
KillDecision::KillIndividually { to_kill, spared }
|
|
580
575
|
}
|
|
581
576
|
|
|
582
577
|
#[derive(Debug, Default)]
|
|
@@ -614,118 +609,178 @@ pub mod lifecycle_port {
|
|
|
614
609
|
}
|
|
615
610
|
|
|
616
611
|
fn bare_shutdown_socket_cleanup(
|
|
612
|
+
workspace: &Path,
|
|
617
613
|
transport: &dyn crate::transport::Transport,
|
|
618
614
|
state: &Value,
|
|
619
615
|
event_log: &crate::event_log::EventLog,
|
|
620
616
|
) -> ShutdownSocketCleanup {
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
if
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
);
|
|
644
|
-
let mut error = None;
|
|
645
|
-
for session in &sessions {
|
|
646
|
-
let targets = vec![session.as_str().to_string()];
|
|
647
|
-
if let Err(audit_error) = crate::kill_audit::pre_kill_audit(
|
|
648
|
-
event_log,
|
|
649
|
-
transport,
|
|
650
|
-
"shutdown.shared_socket_individual",
|
|
651
|
-
crate::kill_audit::KILL_SESSION,
|
|
652
|
-
&targets,
|
|
653
|
-
) {
|
|
654
|
-
error.get_or_insert_with(|| {
|
|
655
|
-
format!("pre-kill audit failed: {audit_error}")
|
|
656
|
-
});
|
|
657
|
-
continue;
|
|
658
|
-
}
|
|
659
|
-
if let Err(err) = transport.kill_session(session) {
|
|
660
|
-
if !tmux_absent_error(&err.to_string()) {
|
|
661
|
-
error.get_or_insert_with(|| err.to_string());
|
|
662
|
-
}
|
|
663
|
-
}
|
|
617
|
+
// A bare shutdown is scoped to sessions registered in state. Never turn a
|
|
618
|
+
// socket-wide enumeration into an authorization decision: a shared/default
|
|
619
|
+
// tmux socket may contain unrelated teams and the absence of a marker is
|
|
620
|
+
// deliberately treated as Unknown.
|
|
621
|
+
let mut candidates = Vec::new();
|
|
622
|
+
if let Some(session) = state
|
|
623
|
+
.get("session_name")
|
|
624
|
+
.and_then(Value::as_str)
|
|
625
|
+
.filter(|name| !name.is_empty())
|
|
626
|
+
{
|
|
627
|
+
candidates.push(crate::transport::SessionName::new(session));
|
|
628
|
+
}
|
|
629
|
+
if let Some(teams) = state.get("teams").and_then(Value::as_object) {
|
|
630
|
+
for team in teams.values() {
|
|
631
|
+
if let Some(session) = team
|
|
632
|
+
.get("session_name")
|
|
633
|
+
.and_then(Value::as_str)
|
|
634
|
+
.filter(|name| !name.is_empty())
|
|
635
|
+
{
|
|
636
|
+
let session = crate::transport::SessionName::new(session);
|
|
637
|
+
if !candidates.iter().any(|existing| existing == &session) {
|
|
638
|
+
candidates.push(session);
|
|
664
639
|
}
|
|
665
|
-
return ShutdownSocketCleanup {
|
|
666
|
-
killed_sessions: sessions,
|
|
667
|
-
spared_sessions: Vec::new(),
|
|
668
|
-
error,
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
let targets = sessions
|
|
672
|
-
.iter()
|
|
673
|
-
.map(|session| session.as_str().to_string())
|
|
674
|
-
.collect::<Vec<_>>();
|
|
675
|
-
let error = match crate::kill_audit::pre_kill_audit(
|
|
676
|
-
event_log,
|
|
677
|
-
transport,
|
|
678
|
-
"shutdown.exclusive_socket",
|
|
679
|
-
crate::kill_audit::KILL_SERVER,
|
|
680
|
-
&targets,
|
|
681
|
-
) {
|
|
682
|
-
Ok(()) => transport.kill_server().err().map(|error| error.to_string()),
|
|
683
|
-
Err(error) => Some(format!("pre-kill audit failed: {error}")),
|
|
684
|
-
};
|
|
685
|
-
ShutdownSocketCleanup {
|
|
686
|
-
killed_sessions: sessions,
|
|
687
|
-
spared_sessions: Vec::new(),
|
|
688
|
-
error,
|
|
689
640
|
}
|
|
690
641
|
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
"killed_sessions": to_kill.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
699
|
-
}),
|
|
700
|
-
);
|
|
642
|
+
}
|
|
643
|
+
let targets = match transport.list_targets() {
|
|
644
|
+
Ok(targets) => targets,
|
|
645
|
+
Err(error) => {
|
|
646
|
+
return ShutdownSocketCleanup {
|
|
647
|
+
error: Some(format!("session ownership probe failed: {error}")),
|
|
648
|
+
..ShutdownSocketCleanup::default()
|
|
701
649
|
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
let protected_sessions = anchor_sessions_from_state(state, &targets, event_log);
|
|
653
|
+
let mut result = ShutdownSocketCleanup::default();
|
|
654
|
+
for session in candidates {
|
|
655
|
+
if protected_sessions.contains(session.as_str()) {
|
|
656
|
+
push_unique_session(&mut result.spared_sessions, session);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
match session_ownership(workspace, state, transport, &session, &targets) {
|
|
660
|
+
SessionOwnership::Owned => {
|
|
661
|
+
let target = vec![session.as_str().to_string()];
|
|
662
|
+
if let Err(error) = crate::kill_audit::pre_kill_audit_scoped(
|
|
706
663
|
event_log,
|
|
707
664
|
transport,
|
|
708
|
-
"shutdown.
|
|
665
|
+
"shutdown.state_owned_session",
|
|
709
666
|
crate::kill_audit::KILL_SESSION,
|
|
710
|
-
&
|
|
667
|
+
&target,
|
|
668
|
+
workspace,
|
|
669
|
+
state_for_session(state, session.as_str())
|
|
670
|
+
.and_then(|identity| identity.get("team_key").and_then(Value::as_str)),
|
|
671
|
+
state_for_session(state, session.as_str()).and_then(state_generation),
|
|
672
|
+
"session_marker+state_session+pane_path",
|
|
673
|
+
"positive workspace/team ownership",
|
|
711
674
|
) {
|
|
712
|
-
error
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
if let Err(err) = transport.kill_session(session) {
|
|
717
|
-
if !tmux_absent_error(&err.to_string()) {
|
|
718
|
-
error.get_or_insert_with(|| err.to_string());
|
|
675
|
+
result.error.get_or_insert_with(|| format!("pre-kill audit failed: {error}"));
|
|
676
|
+
} else if let Err(error) = transport.kill_session(&session) {
|
|
677
|
+
if !tmux_absent_error(&error.to_string()) {
|
|
678
|
+
result.error.get_or_insert_with(|| error.to_string());
|
|
719
679
|
}
|
|
680
|
+
} else {
|
|
681
|
+
result.killed_sessions.push(session);
|
|
720
682
|
}
|
|
721
683
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
684
|
+
SessionOwnership::Foreign => {
|
|
685
|
+
push_unique_session(&mut result.spared_sessions, session);
|
|
686
|
+
}
|
|
687
|
+
SessionOwnership::Unknown => {
|
|
688
|
+
push_unique_session(&mut result.spared_sessions, session);
|
|
689
|
+
result.error.get_or_insert_with(|| "session ownership is unknown; destructive cleanup refused".to_string());
|
|
726
690
|
}
|
|
691
|
+
SessionOwnership::Gone => {}
|
|
727
692
|
}
|
|
728
693
|
}
|
|
694
|
+
result
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
698
|
+
enum SessionOwnership {
|
|
699
|
+
Owned,
|
|
700
|
+
Foreign,
|
|
701
|
+
Unknown,
|
|
702
|
+
Gone,
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
fn state_generation(state: &Value) -> Option<&str> {
|
|
706
|
+
state
|
|
707
|
+
.get("generation")
|
|
708
|
+
.and_then(Value::as_str)
|
|
709
|
+
.or_else(|| {
|
|
710
|
+
state
|
|
711
|
+
.get("agents")
|
|
712
|
+
.and_then(Value::as_object)
|
|
713
|
+
.and_then(|agents| {
|
|
714
|
+
agents.values().find_map(|agent| {
|
|
715
|
+
agent.get("spawned_at").and_then(Value::as_str)
|
|
716
|
+
})
|
|
717
|
+
})
|
|
718
|
+
})
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
fn state_for_session<'a>(state: &'a Value, session: &str) -> Option<&'a Value> {
|
|
722
|
+
if state.get("session_name").and_then(Value::as_str) == Some(session) {
|
|
723
|
+
return Some(state);
|
|
724
|
+
}
|
|
725
|
+
state
|
|
726
|
+
.get("teams")
|
|
727
|
+
.and_then(Value::as_object)
|
|
728
|
+
.and_then(|teams| teams.values().find_map(|team| state_for_session(team, session)))
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
fn session_ownership(
|
|
732
|
+
workspace: &Path,
|
|
733
|
+
state: &Value,
|
|
734
|
+
transport: &dyn crate::transport::Transport,
|
|
735
|
+
session: &crate::transport::SessionName,
|
|
736
|
+
targets: &[crate::transport::PaneInfo],
|
|
737
|
+
) -> SessionOwnership {
|
|
738
|
+
let identity_state = state_for_session(state, session.as_str()).unwrap_or(state);
|
|
739
|
+
let session_targets = targets
|
|
740
|
+
.iter()
|
|
741
|
+
.filter(|target| target.session.as_str() == session.as_str())
|
|
742
|
+
.collect::<Vec<_>>();
|
|
743
|
+
if session_targets.is_empty() {
|
|
744
|
+
return SessionOwnership::Gone;
|
|
745
|
+
}
|
|
746
|
+
let owner = match transport.session_owner(session) {
|
|
747
|
+
Ok(Some(owner)) => owner,
|
|
748
|
+
Ok(None) | Err(_) => return SessionOwnership::Unknown,
|
|
749
|
+
};
|
|
750
|
+
let canonical = workspace
|
|
751
|
+
.canonicalize()
|
|
752
|
+
.unwrap_or_else(|_| workspace.to_path_buf())
|
|
753
|
+
.to_string_lossy()
|
|
754
|
+
.to_string();
|
|
755
|
+
if owner.workspace != canonical {
|
|
756
|
+
return SessionOwnership::Foreign;
|
|
757
|
+
}
|
|
758
|
+
let Some(expected_team) = identity_state
|
|
759
|
+
.get("team_key")
|
|
760
|
+
.or_else(|| identity_state.get("active_team_key"))
|
|
761
|
+
.and_then(Value::as_str)
|
|
762
|
+
.filter(|team| !team.is_empty())
|
|
763
|
+
else {
|
|
764
|
+
return SessionOwnership::Unknown;
|
|
765
|
+
};
|
|
766
|
+
if owner.team != expected_team {
|
|
767
|
+
return SessionOwnership::Foreign;
|
|
768
|
+
}
|
|
769
|
+
let Some(expected_generation) = state_generation(identity_state)
|
|
770
|
+
.filter(|generation| !generation.is_empty()) else {
|
|
771
|
+
return SessionOwnership::Unknown;
|
|
772
|
+
};
|
|
773
|
+
if owner.generation != expected_generation {
|
|
774
|
+
return SessionOwnership::Foreign;
|
|
775
|
+
}
|
|
776
|
+
if session_targets.iter().any(|target| {
|
|
777
|
+
target.current_path.as_ref().map_or(true, |path| {
|
|
778
|
+
!path_is_under(path.as_path(), workspace)
|
|
779
|
+
})
|
|
780
|
+
}) {
|
|
781
|
+
return SessionOwnership::Unknown;
|
|
782
|
+
}
|
|
783
|
+
SessionOwnership::Owned
|
|
729
784
|
}
|
|
730
785
|
|
|
731
786
|
#[cfg(test)]
|
|
@@ -761,10 +816,12 @@ pub mod lifecycle_port {
|
|
|
761
816
|
leader_env: BTreeMap::new(),
|
|
762
817
|
}]);
|
|
763
818
|
let cleanup = bare_shutdown_socket_cleanup(
|
|
819
|
+
Path::new("/tmp/team-agent-test"),
|
|
764
820
|
&transport,
|
|
765
821
|
&json!({
|
|
766
822
|
"is_external_leader": true,
|
|
767
823
|
"tmux_socket_source": "leader_env",
|
|
824
|
+
"session_name": "team-audit-fail",
|
|
768
825
|
}),
|
|
769
826
|
&event_log,
|
|
770
827
|
);
|
|
@@ -776,7 +833,7 @@ pub mod lifecycle_port {
|
|
|
776
833
|
assert!(cleanup
|
|
777
834
|
.error
|
|
778
835
|
.as_deref()
|
|
779
|
-
.is_some_and(|error| error.contains("
|
|
836
|
+
.is_some_and(|error| error.contains("ownership is unknown")));
|
|
780
837
|
let _ = std::fs::remove_dir_all(root);
|
|
781
838
|
}
|
|
782
839
|
}
|
|
@@ -785,106 +842,6 @@ pub mod lifecycle_port {
|
|
|
785
842
|
crate::state::projection::state_is_external_leader(state)
|
|
786
843
|
}
|
|
787
844
|
|
|
788
|
-
fn managed_leader_socket_cleanup(
|
|
789
|
-
transport: &dyn crate::transport::Transport,
|
|
790
|
-
state: &Value,
|
|
791
|
-
sessions: &[crate::transport::SessionName],
|
|
792
|
-
event_log: &crate::event_log::EventLog,
|
|
793
|
-
) -> ShutdownSocketCleanup {
|
|
794
|
-
let target = state
|
|
795
|
-
.get("session_name")
|
|
796
|
-
.and_then(Value::as_str)
|
|
797
|
-
.filter(|session| !session.is_empty())
|
|
798
|
-
.map(crate::transport::SessionName::new);
|
|
799
|
-
// E49 (0.3.24 P0, shutdown kills leader CLI): for managed leader topology
|
|
800
|
-
// the target session may carry the leader anchor pane. The pre-fix code
|
|
801
|
-
// pushed it into `to_kill` and then issued `kill_session` — which ended
|
|
802
|
-
// the leader pane (= leader CLI). When the target session has a live
|
|
803
|
-
// anchor pane in `list_targets`, spare it instead.
|
|
804
|
-
let leader_anchor_ids = collect_state_leader_anchor_pane_ids(state);
|
|
805
|
-
let live_targets = transport.list_targets().unwrap_or_default();
|
|
806
|
-
let mut to_kill = Vec::new();
|
|
807
|
-
let mut target_spared_for_anchor: Option<crate::transport::SessionName> = None;
|
|
808
|
-
if let Some(target) = target {
|
|
809
|
-
let target_has_anchor = live_targets.iter().any(|t| {
|
|
810
|
-
t.session.as_str() == target.as_str()
|
|
811
|
-
&& leader_anchor_ids.contains(t.pane_id.as_str())
|
|
812
|
-
});
|
|
813
|
-
if target_has_anchor {
|
|
814
|
-
target_spared_for_anchor = Some(target);
|
|
815
|
-
} else if sessions.is_empty()
|
|
816
|
-
|| sessions
|
|
817
|
-
.iter()
|
|
818
|
-
.any(|session| session.as_str() == target.as_str())
|
|
819
|
-
{
|
|
820
|
-
to_kill.push(target);
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
let mut spared = sessions
|
|
824
|
-
.iter()
|
|
825
|
-
.filter(|session| {
|
|
826
|
-
!to_kill
|
|
827
|
-
.iter()
|
|
828
|
-
.any(|target| target.as_str() == session.as_str())
|
|
829
|
-
})
|
|
830
|
-
.cloned()
|
|
831
|
-
.collect::<Vec<_>>();
|
|
832
|
-
if let Some(anchored) = target_spared_for_anchor {
|
|
833
|
-
if !spared.iter().any(|s| s.as_str() == anchored.as_str()) {
|
|
834
|
-
spared.push(anchored);
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
let _ = event_log.write(
|
|
838
|
-
"shutdown.kill_server_skipped_managed_leader",
|
|
839
|
-
json!({
|
|
840
|
-
"reason": "managed_leader_topology",
|
|
841
|
-
"spared_sessions": spared.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
842
|
-
"killed_sessions": to_kill.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
843
|
-
}),
|
|
844
|
-
);
|
|
845
|
-
let mut error = None;
|
|
846
|
-
for session in &to_kill {
|
|
847
|
-
// 0.3.28 Step 6 (warn-only invariant): the kill list MUST NOT
|
|
848
|
-
// contain a leader session. `sessions_to_kill` already excludes
|
|
849
|
-
// leader-prefixed sessions, but the managed-leader cleanup path
|
|
850
|
-
// (this function) computes its own `to_kill` from `target`. Any
|
|
851
|
-
// leader-prefixed entry here is a topology violation introduced
|
|
852
|
-
// somewhere upstream. Log loudly and skip the kill.
|
|
853
|
-
if session
|
|
854
|
-
.as_str()
|
|
855
|
-
.starts_with(crate::leader::LEADER_SESSION_PREFIX)
|
|
856
|
-
{
|
|
857
|
-
eprintln!(
|
|
858
|
-
"team_agent::layout shutdown_invariant_violation kind=KillListContainsLeaderSession \
|
|
859
|
-
session=`{}` action=skipping_kill (post-Step-9 will hard-fail)",
|
|
860
|
-
session.as_str()
|
|
861
|
-
);
|
|
862
|
-
continue;
|
|
863
|
-
}
|
|
864
|
-
let targets = vec![session.as_str().to_string()];
|
|
865
|
-
if let Err(audit_error) = crate::kill_audit::pre_kill_audit(
|
|
866
|
-
event_log,
|
|
867
|
-
transport,
|
|
868
|
-
"shutdown.managed_worker_session",
|
|
869
|
-
crate::kill_audit::KILL_SESSION,
|
|
870
|
-
&targets,
|
|
871
|
-
) {
|
|
872
|
-
error.get_or_insert_with(|| format!("pre-kill audit failed: {audit_error}"));
|
|
873
|
-
continue;
|
|
874
|
-
}
|
|
875
|
-
if let Err(err) = transport.kill_session(session) {
|
|
876
|
-
if !tmux_absent_error(&err.to_string()) {
|
|
877
|
-
error.get_or_insert_with(|| err.to_string());
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
ShutdownSocketCleanup {
|
|
882
|
-
killed_sessions: to_kill,
|
|
883
|
-
spared_sessions: spared,
|
|
884
|
-
error,
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
|
|
888
845
|
fn owned_shutdown_endpoint(
|
|
889
846
|
workspace: &Path,
|
|
890
847
|
state: &Value,
|
|
@@ -969,40 +926,15 @@ pub mod lifecycle_port {
|
|
|
969
926
|
);
|
|
970
927
|
return cleanup;
|
|
971
928
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
crate::kill_audit::KILL_SERVER,
|
|
977
|
-
&[],
|
|
978
|
-
) {
|
|
979
|
-
cleanup.error = Some(format!("pre-kill audit failed: {error}"));
|
|
980
|
-
return cleanup;
|
|
981
|
-
}
|
|
982
|
-
if let Err(error) = transport.kill_server() {
|
|
983
|
-
cleanup.error = Some(error.to_string());
|
|
984
|
-
}
|
|
985
|
-
if let Some(path) = endpoint.socket_file.as_ref() {
|
|
986
|
-
if path.exists() {
|
|
987
|
-
match std::fs::remove_file(path) {
|
|
988
|
-
Ok(()) => cleanup.removed_files.push(path.display().to_string()),
|
|
989
|
-
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
990
|
-
Err(error) => {
|
|
991
|
-
cleanup.error.get_or_insert_with(|| error.to_string());
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
if path.exists() {
|
|
996
|
-
cleanup.residual_files.push(path.display().to_string());
|
|
997
|
-
}
|
|
998
|
-
}
|
|
929
|
+
// Endpoint ownership is not sufficient authority to tear down a tmux
|
|
930
|
+
// server: an endpoint can be shared after launch. Leave both the
|
|
931
|
+
// server and its socket file intact; only explicitly owned sessions
|
|
932
|
+
// are removed above.
|
|
999
933
|
let _ = event_log.write(
|
|
1000
|
-
"shutdown.
|
|
934
|
+
"shutdown.owned_endpoint_cleanup_skipped",
|
|
1001
935
|
json!({
|
|
1002
936
|
"endpoint": endpoint.endpoint,
|
|
1003
|
-
"
|
|
1004
|
-
"residual_files": cleanup.residual_files.clone(),
|
|
1005
|
-
"error": cleanup.error.clone(),
|
|
937
|
+
"reason": "server_and_socket_cleanup_disabled_fail_closed",
|
|
1006
938
|
}),
|
|
1007
939
|
);
|
|
1008
940
|
cleanup
|
|
@@ -1080,10 +1012,18 @@ pub mod lifecycle_port {
|
|
|
1080
1012
|
root_pids.extend(pane_pids);
|
|
1081
1013
|
root_pids.sort_unstable();
|
|
1082
1014
|
root_pids.dedup();
|
|
1083
|
-
let root_pgids = process_pgids(&root_pids, &protected, &entry_table);
|
|
1084
1015
|
deadline.check("reap_process_tree")?;
|
|
1085
|
-
|
|
1086
|
-
|
|
1016
|
+
let process_event_log = crate::event_log::EventLog::new(&run_workspace);
|
|
1017
|
+
reap_process_tree(
|
|
1018
|
+
&run_workspace,
|
|
1019
|
+
&state,
|
|
1020
|
+
reap_scope,
|
|
1021
|
+
&root_pids,
|
|
1022
|
+
&protected,
|
|
1023
|
+
&entry_table,
|
|
1024
|
+
&process_event_log,
|
|
1025
|
+
);
|
|
1026
|
+
|
|
1087
1027
|
let mut kill_error: Option<String> = None;
|
|
1088
1028
|
let mut killed_sessions = Vec::new();
|
|
1089
1029
|
let mut spared_sessions = Vec::new();
|
|
@@ -1099,57 +1039,111 @@ pub mod lifecycle_port {
|
|
|
1099
1039
|
// session per `list_targets`. When managed-leader, switch to per-pane
|
|
1100
1040
|
// cleanup: kill the workers individually and SPARE the leader's pane.
|
|
1101
1041
|
//
|
|
1102
|
-
// External
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1042
|
+
// External and managed leaders both pass the same positive ownership
|
|
1043
|
+
// gate; topology only decides whether a verified session is reduced
|
|
1044
|
+
// to worker panes or removed as a whole.
|
|
1105
1045
|
let leader_anchor_ids = collect_state_leader_anchor_pane_ids(&state);
|
|
1106
|
-
let live_targets_now = transport.list_targets()
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1046
|
+
let (live_targets_now, live_targets_known) = match transport.list_targets() {
|
|
1047
|
+
Ok(targets) => (targets, true),
|
|
1048
|
+
Err(error) => {
|
|
1049
|
+
push_unique_session(&mut spared_sessions, session.clone());
|
|
1050
|
+
kill_error.get_or_insert_with(|| format!("session ownership probe failed: {error}"));
|
|
1051
|
+
(Vec::new(), false)
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
let ownership = if live_targets_known {
|
|
1055
|
+
session_ownership(
|
|
1056
|
+
&run_workspace,
|
|
1117
1057
|
&state,
|
|
1118
|
-
|
|
1119
|
-
|
|
1058
|
+
transport,
|
|
1059
|
+
session,
|
|
1120
1060
|
&live_targets_now,
|
|
1121
|
-
)
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1061
|
+
)
|
|
1062
|
+
} else {
|
|
1063
|
+
SessionOwnership::Unknown
|
|
1064
|
+
};
|
|
1065
|
+
if ownership != SessionOwnership::Owned {
|
|
1066
|
+
push_unique_session(&mut spared_sessions, session.clone());
|
|
1067
|
+
if ownership == SessionOwnership::Unknown {
|
|
1068
|
+
kill_error.get_or_insert_with(|| "session ownership is unknown; destructive cleanup refused".to_string());
|
|
1128
1069
|
}
|
|
1129
|
-
let _ =
|
|
1130
|
-
crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
|
|
1131
1070
|
} else {
|
|
1132
|
-
let
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
&
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1071
|
+
let session_has_leader_anchor = live_targets_now.iter().any(|target| {
|
|
1072
|
+
target.session.as_str() == session.as_str()
|
|
1073
|
+
&& leader_anchor_ids.contains(target.pane_id.as_str())
|
|
1074
|
+
});
|
|
1075
|
+
if crate::state::projection::state_is_managed_leader(&state)
|
|
1076
|
+
&& session_has_leader_anchor
|
|
1077
|
+
{
|
|
1078
|
+
let _ = event_log_write_session_spared(&run_workspace, session, &leader_anchor_ids);
|
|
1079
|
+
push_unique_session(&mut spared_sessions, session.clone());
|
|
1080
|
+
let worker_panes = collect_session_worker_panes(
|
|
1081
|
+
&state,
|
|
1082
|
+
session.as_str(),
|
|
1083
|
+
&leader_anchor_ids,
|
|
1084
|
+
&live_targets_now,
|
|
1085
|
+
);
|
|
1086
|
+
let event_log = crate::event_log::EventLog::new(&run_workspace);
|
|
1087
|
+
let identity_state = state_for_session(&state, session.as_str()).unwrap_or(&state);
|
|
1088
|
+
for pane in &worker_panes {
|
|
1089
|
+
let pane_target = vec![pane.as_str().to_string()];
|
|
1090
|
+
let audit = crate::kill_audit::pre_kill_audit_scoped(
|
|
1091
|
+
&event_log,
|
|
1092
|
+
transport,
|
|
1093
|
+
"shutdown.managed_worker_pane",
|
|
1094
|
+
"kill-pane",
|
|
1095
|
+
&pane_target,
|
|
1096
|
+
&run_workspace,
|
|
1097
|
+
identity_state.get("team_key").and_then(Value::as_str),
|
|
1098
|
+
state_generation(identity_state),
|
|
1099
|
+
"session_marker+state_worker_pane+pane_path",
|
|
1100
|
+
"positive workspace/team ownership",
|
|
1101
|
+
);
|
|
1102
|
+
if let Err(error) = audit {
|
|
1103
|
+
kill_error.get_or_insert_with(|| format!("pre-kill audit failed: {error}"));
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
if let Err(error) = transport.kill_pane(pane) {
|
|
1107
|
+
if !tmux_absent_error(&error.to_string()) {
|
|
1108
|
+
kill_error.get_or_insert_with(|| error.to_string());
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1147
1111
|
}
|
|
1112
|
+
let _ = crate::lifecycle::display::close_team_display_backends(
|
|
1113
|
+
&run_workspace,
|
|
1114
|
+
session,
|
|
1115
|
+
);
|
|
1148
1116
|
} else {
|
|
1149
|
-
|
|
1117
|
+
let targets = vec![session.as_str().to_string()];
|
|
1118
|
+
let event_log = crate::event_log::EventLog::new(&run_workspace);
|
|
1119
|
+
if let Err(error) = crate::kill_audit::pre_kill_audit_scoped(
|
|
1120
|
+
&event_log,
|
|
1121
|
+
transport,
|
|
1122
|
+
"shutdown.team_session",
|
|
1123
|
+
crate::kill_audit::KILL_SESSION,
|
|
1124
|
+
&targets,
|
|
1125
|
+
&run_workspace,
|
|
1126
|
+
state_for_session(&state, session.as_str())
|
|
1127
|
+
.and_then(|identity| identity.get("team_key").and_then(Value::as_str)),
|
|
1128
|
+
state_for_session(&state, session.as_str()).and_then(state_generation),
|
|
1129
|
+
"session_marker+state_session+pane_path",
|
|
1130
|
+
"positive workspace/team ownership",
|
|
1131
|
+
) {
|
|
1132
|
+
kill_error = Some(format!("pre-kill audit failed: {error}"));
|
|
1133
|
+
} else if let Err(error) = transport.kill_session(session) {
|
|
1134
|
+
if tmux_absent_error(&error.to_string()) {
|
|
1135
|
+
push_unique_session(&mut killed_sessions, session.clone());
|
|
1136
|
+
} else {
|
|
1137
|
+
kill_error = Some(error.to_string());
|
|
1138
|
+
}
|
|
1139
|
+
} else {
|
|
1140
|
+
push_unique_session(&mut killed_sessions, session.clone());
|
|
1141
|
+
}
|
|
1142
|
+
let _ = crate::lifecycle::display::close_team_display_backends(
|
|
1143
|
+
&run_workspace,
|
|
1144
|
+
session,
|
|
1145
|
+
);
|
|
1150
1146
|
}
|
|
1151
|
-
let _ =
|
|
1152
|
-
crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
|
|
1153
1147
|
}
|
|
1154
1148
|
}
|
|
1155
1149
|
deadline.check("reap_workspace_residuals")?;
|
|
@@ -1157,7 +1151,6 @@ pub mod lifecycle_port {
|
|
|
1157
1151
|
&run_workspace,
|
|
1158
1152
|
&state,
|
|
1159
1153
|
&root_pids,
|
|
1160
|
-
&root_pgids,
|
|
1161
1154
|
transport,
|
|
1162
1155
|
reap_scope,
|
|
1163
1156
|
&mut probe_degraded,
|
|
@@ -1165,7 +1158,7 @@ pub mod lifecycle_port {
|
|
|
1165
1158
|
if team.is_none() {
|
|
1166
1159
|
deadline.check("shared_socket_cleanup")?;
|
|
1167
1160
|
let event_log = crate::event_log::EventLog::new(&run_workspace);
|
|
1168
|
-
let cleanup = bare_shutdown_socket_cleanup(transport, &state, &event_log);
|
|
1161
|
+
let cleanup = bare_shutdown_socket_cleanup(&run_workspace, transport, &state, &event_log);
|
|
1169
1162
|
for session in cleanup.killed_sessions {
|
|
1170
1163
|
push_unique_session(&mut killed_sessions, session);
|
|
1171
1164
|
}
|
|
@@ -1174,6 +1167,7 @@ pub mod lifecycle_port {
|
|
|
1174
1167
|
kill_error.get_or_insert(error);
|
|
1175
1168
|
}
|
|
1176
1169
|
}
|
|
1170
|
+
spared_sessions.retain(|session| transport.has_session(session).unwrap_or(true));
|
|
1177
1171
|
deadline.check("session_residuals")?;
|
|
1178
1172
|
let (session_residuals, session_residual_error) = session_residuals_after_reap_many(
|
|
1179
1173
|
transport,
|
|
@@ -1207,7 +1201,6 @@ pub mod lifecycle_port {
|
|
|
1207
1201
|
&run_workspace,
|
|
1208
1202
|
&state,
|
|
1209
1203
|
&root_pids,
|
|
1210
|
-
&root_pgids,
|
|
1211
1204
|
&protected,
|
|
1212
1205
|
reap_scope,
|
|
1213
1206
|
&verify_table,
|
|
@@ -1837,12 +1830,25 @@ pub mod lifecycle_port {
|
|
|
1837
1830
|
/// pids; Gap 37 escalation order TERM -> grace -> KILL preserved), then a single
|
|
1838
1831
|
/// bounded wait for the whole union. kill/wait sets derive from the SAME snapshot
|
|
1839
1832
|
/// as the protected set (N39).
|
|
1840
|
-
fn reap_process_tree(
|
|
1833
|
+
fn reap_process_tree(
|
|
1834
|
+
workspace: &Path,
|
|
1835
|
+
state: &Value,
|
|
1836
|
+
scope: ShutdownReapScope,
|
|
1837
|
+
root_pids: &[u32],
|
|
1838
|
+
protected: &ShutdownProtection,
|
|
1839
|
+
table: &[ProcessInfo],
|
|
1840
|
+
event_log: &crate::event_log::EventLog,
|
|
1841
|
+
) {
|
|
1841
1842
|
let mut pids = Vec::new();
|
|
1842
1843
|
let mut seen = std::collections::BTreeSet::new();
|
|
1844
|
+
let spawn_cwds = state_spawn_cwds(state, scope);
|
|
1843
1845
|
for root in root_pids {
|
|
1844
1846
|
for pid in process_tree_from_table(*root, table) {
|
|
1845
|
-
|
|
1847
|
+
let owned = table
|
|
1848
|
+
.iter()
|
|
1849
|
+
.find(|process| process.pid == pid)
|
|
1850
|
+
.is_some_and(|process| process_matches_workspace(process, workspace, &spawn_cwds));
|
|
1851
|
+
if owned && !protected.contains_pid(pid) && seen.insert(pid) {
|
|
1846
1852
|
pids.push(pid);
|
|
1847
1853
|
}
|
|
1848
1854
|
}
|
|
@@ -1850,50 +1856,58 @@ pub mod lifecycle_port {
|
|
|
1850
1856
|
if pids.is_empty() {
|
|
1851
1857
|
return;
|
|
1852
1858
|
}
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1859
|
+
let audit_targets = pids.iter().map(u32::to_string).collect::<Vec<_>>();
|
|
1860
|
+
if event_log
|
|
1861
|
+
.write(
|
|
1862
|
+
"transport.pre_kill_audit",
|
|
1863
|
+
json!({
|
|
1864
|
+
"phase": "pre_call",
|
|
1865
|
+
"caller": "shutdown.process_tree",
|
|
1866
|
+
"caller_pid": std::process::id(),
|
|
1867
|
+
"action": "signal-pid",
|
|
1868
|
+
"endpoint": Value::Null,
|
|
1869
|
+
"targets": audit_targets,
|
|
1870
|
+
"workspace": workspace.to_string_lossy(),
|
|
1871
|
+
"team": state.get("team_key").and_then(Value::as_str),
|
|
1872
|
+
"generation": state_generation(state),
|
|
1873
|
+
"owner_evidence": "argv+cwd+state_pid_tree",
|
|
1874
|
+
"reason": "positive per-pid process ownership",
|
|
1875
|
+
}),
|
|
1876
|
+
)
|
|
1877
|
+
.is_err()
|
|
1878
|
+
{
|
|
1879
|
+
return;
|
|
1858
1880
|
}
|
|
1859
|
-
std::thread::sleep(std::time::Duration::from_millis(150));
|
|
1860
1881
|
for pid in pids.iter().rev() {
|
|
1861
1882
|
let _ = crate::platform::process::terminate_pid(
|
|
1862
1883
|
*pid,
|
|
1863
|
-
crate::platform::process::SignalKind::TerminateForce,
|
|
1864
|
-
);
|
|
1865
|
-
}
|
|
1866
|
-
wait_for_processes_gone(&pids, std::time::Duration::from_secs(1));
|
|
1867
|
-
}
|
|
1868
|
-
|
|
1869
|
-
fn reap_process_groups(pgids: &[u32], protected: &ShutdownProtection) {
|
|
1870
|
-
// 0.5.x Windows portability Batch 3: routes group termination
|
|
1871
|
-
// through `platform::process::terminate_group`. Unix keeps
|
|
1872
|
-
// `kill(-pgid, SIGTERM|SIGKILL)` semantics byte-for-byte;
|
|
1873
|
-
// Windows returns AlreadyGone (no pgid concept — Job Object
|
|
1874
|
-
// teardown is the shim-side concern per design §Route B).
|
|
1875
|
-
// The `pgid_t <= 1 || protected.contains_pgid(...)` filter
|
|
1876
|
-
// stays in place so pgid 0/1 (kernel/init) and the caller's
|
|
1877
|
-
// own group are never targeted.
|
|
1878
|
-
for pgid in pgids {
|
|
1879
|
-
if *pgid <= 1 || protected.contains_pgid(*pgid) {
|
|
1880
|
-
continue;
|
|
1881
|
-
}
|
|
1882
|
-
let _ = crate::platform::process::terminate_group(
|
|
1883
|
-
*pgid,
|
|
1884
1884
|
crate::platform::process::SignalKind::TerminateGraceful,
|
|
1885
1885
|
);
|
|
1886
1886
|
}
|
|
1887
1887
|
std::thread::sleep(std::time::Duration::from_millis(150));
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1888
|
+
// Re-probe identity before escalation. If the probe fails, refuse the
|
|
1889
|
+
// force signal rather than risking a recycled PID.
|
|
1890
|
+
if let Ok(live_table) = probed_process_table() {
|
|
1891
|
+
let spawn_cwds = state_spawn_cwds(state, scope);
|
|
1892
|
+
for pid in pids.iter().rev() {
|
|
1893
|
+
let Some(before) = table.iter().find(|process| process.pid == *pid) else {
|
|
1894
|
+
continue;
|
|
1895
|
+
};
|
|
1896
|
+
let Some(after) = live_table.iter().find(|process| process.pid == *pid) else {
|
|
1897
|
+
continue;
|
|
1898
|
+
};
|
|
1899
|
+
if before.ppid == after.ppid
|
|
1900
|
+
&& before.command == after.command
|
|
1901
|
+
&& process_matches_workspace(after, workspace, &spawn_cwds)
|
|
1902
|
+
{
|
|
1903
|
+
let _ = crate::platform::process::terminate_pid(
|
|
1904
|
+
*pid,
|
|
1905
|
+
crate::platform::process::SignalKind::TerminateForce,
|
|
1906
|
+
);
|
|
1907
|
+
}
|
|
1891
1908
|
}
|
|
1892
|
-
let _ = crate::platform::process::terminate_group(
|
|
1893
|
-
*pgid,
|
|
1894
|
-
crate::platform::process::SignalKind::TerminateForce,
|
|
1895
|
-
);
|
|
1896
1909
|
}
|
|
1910
|
+
wait_for_processes_gone(&pids, std::time::Duration::from_secs(1));
|
|
1897
1911
|
}
|
|
1898
1912
|
|
|
1899
1913
|
/// PERF-6 C-①-2 + C-②-5: every residual round fetches ONE fresh snapshot (reap
|
|
@@ -1903,7 +1917,6 @@ pub mod lifecycle_port {
|
|
|
1903
1917
|
workspace: &Path,
|
|
1904
1918
|
state: &Value,
|
|
1905
1919
|
root_pids: &[u32],
|
|
1906
|
-
root_pgids: &[u32],
|
|
1907
1920
|
transport: &dyn crate::transport::Transport,
|
|
1908
1921
|
scope: ShutdownReapScope,
|
|
1909
1922
|
probe_degraded: &mut bool,
|
|
@@ -1916,7 +1929,6 @@ pub mod lifecycle_port {
|
|
|
1916
1929
|
workspace,
|
|
1917
1930
|
state,
|
|
1918
1931
|
root_pids,
|
|
1919
|
-
root_pgids,
|
|
1920
1932
|
&protected,
|
|
1921
1933
|
scope,
|
|
1922
1934
|
&round_table,
|
|
@@ -1928,12 +1940,15 @@ pub mod lifecycle_port {
|
|
|
1928
1940
|
.iter()
|
|
1929
1941
|
.map(|process| process.pid)
|
|
1930
1942
|
.collect::<Vec<_>>();
|
|
1931
|
-
reap_process_tree(
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1943
|
+
reap_process_tree(
|
|
1944
|
+
workspace,
|
|
1945
|
+
state,
|
|
1946
|
+
scope,
|
|
1947
|
+
&residual_pids,
|
|
1948
|
+
&protected,
|
|
1949
|
+
&round_table,
|
|
1950
|
+
&crate::event_log::EventLog::new(workspace),
|
|
1951
|
+
);
|
|
1937
1952
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
1938
1953
|
}
|
|
1939
1954
|
}
|
|
@@ -2115,9 +2130,9 @@ pub mod lifecycle_port {
|
|
|
2115
2130
|
if leader_anchor_ids.contains(pane_id) {
|
|
2116
2131
|
continue;
|
|
2117
2132
|
}
|
|
2118
|
-
let in_live = live_targets
|
|
2119
|
-
.
|
|
2120
|
-
|
|
2133
|
+
let in_live = live_targets.iter().any(|info| {
|
|
2134
|
+
info.pane_id.as_str() == pane_id && info.session.as_str() == session
|
|
2135
|
+
});
|
|
2121
2136
|
if in_live && seen.insert(pane_id.to_string()) {
|
|
2122
2137
|
out.push(crate::transport::PaneId::new(pane_id));
|
|
2123
2138
|
continue;
|
|
@@ -2385,44 +2400,16 @@ pub mod lifecycle_port {
|
|
|
2385
2400
|
}
|
|
2386
2401
|
}
|
|
2387
2402
|
|
|
2388
|
-
fn process_pgids(
|
|
2389
|
-
pids: &[u32],
|
|
2390
|
-
protected: &ShutdownProtection,
|
|
2391
|
-
table: &[ProcessInfo],
|
|
2392
|
-
) -> Vec<u32> {
|
|
2393
|
-
let mut pgids = pids
|
|
2394
|
-
.iter()
|
|
2395
|
-
.filter_map(|pid| table.iter().find(|process| process.pid == *pid))
|
|
2396
|
-
.filter_map(|process| process.pgid)
|
|
2397
|
-
.filter(|pgid| {
|
|
2398
|
-
// 0.5.x Windows portability Batch 4: `libc::pid_t` was
|
|
2399
|
-
// used here as a signed-integer conversion gate to
|
|
2400
|
-
// reject values > INT_MAX. Replace with the equivalent
|
|
2401
|
-
// `i32::try_from` (pgid_t is `c_int` on every Unix we
|
|
2402
|
-
// support). Windows has no pgid concept so `pgids`
|
|
2403
|
-
// is empty in practice — the filter is dead code on
|
|
2404
|
-
// Windows but must still compile.
|
|
2405
|
-
i32::try_from(*pgid)
|
|
2406
|
-
.map(|pgid_int| pgid_int > 1 && !protected.contains_pgid(*pgid))
|
|
2407
|
-
.unwrap_or(false)
|
|
2408
|
-
})
|
|
2409
|
-
.collect::<Vec<_>>();
|
|
2410
|
-
pgids.sort_unstable();
|
|
2411
|
-
pgids.dedup();
|
|
2412
|
-
pgids
|
|
2413
|
-
}
|
|
2414
|
-
|
|
2415
2403
|
fn process_residuals(
|
|
2416
2404
|
workspace: &Path,
|
|
2417
2405
|
state: &Value,
|
|
2418
2406
|
root_pids: &[u32],
|
|
2419
|
-
root_pgids: &[u32],
|
|
2420
2407
|
protected: &ShutdownProtection,
|
|
2421
2408
|
scope: ShutdownReapScope,
|
|
2422
2409
|
table: &[ProcessInfo],
|
|
2423
2410
|
) -> Vec<Value> {
|
|
2424
2411
|
let mut residuals = matched_processes(
|
|
2425
|
-
workspace, state, root_pids,
|
|
2412
|
+
workspace, state, root_pids, protected, scope, table,
|
|
2426
2413
|
);
|
|
2427
2414
|
let mut seen = residuals
|
|
2428
2415
|
.iter()
|
|
@@ -2460,7 +2447,6 @@ pub mod lifecycle_port {
|
|
|
2460
2447
|
workspace: &Path,
|
|
2461
2448
|
state: &Value,
|
|
2462
2449
|
root_pids: &[u32],
|
|
2463
|
-
root_pgids: &[u32],
|
|
2464
2450
|
protected: &ShutdownProtection,
|
|
2465
2451
|
scope: ShutdownReapScope,
|
|
2466
2452
|
table: &[ProcessInfo],
|
|
@@ -2470,33 +2456,14 @@ pub mod lifecycle_port {
|
|
|
2470
2456
|
.flat_map(|pid| process_tree_from_table(*pid, table))
|
|
2471
2457
|
.filter(|pid| !protected.contains_pid(*pid))
|
|
2472
2458
|
.collect::<std::collections::BTreeSet<_>>();
|
|
2473
|
-
let root_pgids = root_pgids
|
|
2474
|
-
.iter()
|
|
2475
|
-
.copied()
|
|
2476
|
-
.collect::<std::collections::BTreeSet<_>>();
|
|
2477
2459
|
let spawn_cwds = state_spawn_cwds(state, scope);
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
let matches_workspace = scope == ShutdownReapScope::Workspace
|
|
2486
|
-
&& process_matches_workspace(
|
|
2487
|
-
process,
|
|
2488
|
-
&workspace_text,
|
|
2489
|
-
&spawn_cwds,
|
|
2490
|
-
&mut cwd_probe_budget,
|
|
2491
|
-
);
|
|
2492
|
-
if matches_workspace
|
|
2493
|
-
|| root_tree.contains(&process.pid)
|
|
2494
|
-
|| process.pgid.is_some_and(|pgid| root_pgids.contains(&pgid))
|
|
2495
|
-
{
|
|
2496
|
-
out.push(process.clone());
|
|
2497
|
-
}
|
|
2498
|
-
}
|
|
2499
|
-
out
|
|
2460
|
+
table
|
|
2461
|
+
.iter()
|
|
2462
|
+
.filter(|process| !protected.contains_pid(process.pid))
|
|
2463
|
+
.filter(|process| root_tree.contains(&process.pid))
|
|
2464
|
+
.filter(|process| process_matches_workspace(process, workspace, &spawn_cwds))
|
|
2465
|
+
.cloned()
|
|
2466
|
+
.collect()
|
|
2500
2467
|
}
|
|
2501
2468
|
|
|
2502
2469
|
fn process_tree_from_table(root_pid: u32, table: &[ProcessInfo]) -> Vec<u32> {
|
|
@@ -2549,59 +2516,21 @@ pub mod lifecycle_port {
|
|
|
2549
2516
|
|
|
2550
2517
|
fn process_matches_workspace(
|
|
2551
2518
|
process: &ProcessInfo,
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
cwd_probe_budget: &mut usize,
|
|
2519
|
+
workspace: &Path,
|
|
2520
|
+
_spawn_cwds: &[PathBuf],
|
|
2555
2521
|
) -> bool {
|
|
2556
|
-
let
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
}
|
|
2569
|
-
*cwd_probe_budget -= 1;
|
|
2570
|
-
let Some(cwd) = process_cwd(process.pid) else {
|
|
2571
|
-
return false;
|
|
2572
|
-
};
|
|
2573
|
-
spawn_cwds
|
|
2574
|
-
.iter()
|
|
2575
|
-
.any(|spawn_cwd| path_is_under(&cwd, spawn_cwd))
|
|
2576
|
-
}
|
|
2577
|
-
|
|
2578
|
-
fn process_cwd(pid: u32) -> Option<PathBuf> {
|
|
2579
|
-
let proc_cwd = PathBuf::from(format!("/proc/{pid}/cwd"));
|
|
2580
|
-
if let Ok(path) = std::fs::read_link(proc_cwd) {
|
|
2581
|
-
return Some(path);
|
|
2582
|
-
}
|
|
2583
|
-
if crate::os_probe::probe_timed_out() {
|
|
2584
|
-
return None;
|
|
2585
|
-
}
|
|
2586
|
-
let output = crate::os_probe::bounded_command_output_with_probe(
|
|
2587
|
-
std::process::Command::new("lsof").args([
|
|
2588
|
-
"-a",
|
|
2589
|
-
"-p",
|
|
2590
|
-
&pid.to_string(),
|
|
2591
|
-
"-d",
|
|
2592
|
-
"cwd",
|
|
2593
|
-
"-Fn",
|
|
2594
|
-
]),
|
|
2595
|
-
"lsof_cwd",
|
|
2596
|
-
Some(pid),
|
|
2597
|
-
)
|
|
2598
|
-
.ok()?;
|
|
2599
|
-
if !output.status.success() {
|
|
2600
|
-
return None;
|
|
2601
|
-
}
|
|
2602
|
-
String::from_utf8_lossy(&output.stdout)
|
|
2603
|
-
.lines()
|
|
2604
|
-
.find_map(|line| line.strip_prefix('n').map(PathBuf::from))
|
|
2522
|
+
let workspace = workspace
|
|
2523
|
+
.canonicalize()
|
|
2524
|
+
.unwrap_or_else(|_| workspace.to_path_buf());
|
|
2525
|
+
crate::platform::argv::argv_tokens(process.pid).is_some_and(|tokens| {
|
|
2526
|
+
tokens.windows(2).any(|pair| {
|
|
2527
|
+
pair[0] == "--workspace"
|
|
2528
|
+
&& Path::new(&pair[1])
|
|
2529
|
+
.canonicalize()
|
|
2530
|
+
.map(|path| path == workspace)
|
|
2531
|
+
.unwrap_or(false)
|
|
2532
|
+
})
|
|
2533
|
+
})
|
|
2605
2534
|
}
|
|
2606
2535
|
|
|
2607
2536
|
fn path_is_under(path: &Path, root: &Path) -> bool {
|
|
@@ -4487,6 +4416,7 @@ pub mod lifecycle_port {
|
|
|
4487
4416
|
match status {
|
|
4488
4417
|
crate::coordinator::StopOutcome::Missing => "missing",
|
|
4489
4418
|
crate::coordinator::StopOutcome::InvalidPidRemoved => "invalid_pid_removed",
|
|
4419
|
+
crate::coordinator::StopOutcome::NotOwned => "not_owned",
|
|
4490
4420
|
crate::coordinator::StopOutcome::KillFailed => "kill_failed",
|
|
4491
4421
|
crate::coordinator::StopOutcome::Stopped => "stopped",
|
|
4492
4422
|
}
|
|
@@ -5050,17 +4980,44 @@ pub mod diagnose_port {
|
|
|
5050
4980
|
return;
|
|
5051
4981
|
}
|
|
5052
4982
|
for (idx, line) in text.lines().enumerate() {
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
4983
|
+
let Some(key) = secret_assignment_key(line) else {
|
|
4984
|
+
continue;
|
|
4985
|
+
};
|
|
4986
|
+
let rel = path.strip_prefix(root).unwrap_or(path);
|
|
4987
|
+
findings.push(json!({
|
|
4988
|
+
"path": rel.to_string_lossy().to_string(),
|
|
4989
|
+
"line": idx.saturating_add(1),
|
|
4990
|
+
"rule": "api_key_assignment",
|
|
4991
|
+
"match_excerpt": format!("{key}=<redacted>"),
|
|
4992
|
+
}));
|
|
5062
4993
|
}
|
|
5063
4994
|
}
|
|
4995
|
+
|
|
4996
|
+
fn secret_assignment_key(line: &str) -> Option<&'static str> {
|
|
4997
|
+
let line = line.trim_start();
|
|
4998
|
+
let line = match line.strip_prefix("export") {
|
|
4999
|
+
Some(rest) if rest.chars().next().is_some_and(char::is_whitespace) => rest.trim_start(),
|
|
5000
|
+
_ => line,
|
|
5001
|
+
};
|
|
5002
|
+
["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]
|
|
5003
|
+
.into_iter()
|
|
5004
|
+
.find(|key| {
|
|
5005
|
+
line.strip_prefix(*key)
|
|
5006
|
+
.and_then(|rest| rest.strip_prefix('='))
|
|
5007
|
+
.is_some_and(|rhs| {
|
|
5008
|
+
let rhs = rhs.trim();
|
|
5009
|
+
let rhs = rhs
|
|
5010
|
+
.strip_prefix('"')
|
|
5011
|
+
.and_then(|value| value.strip_suffix('"'))
|
|
5012
|
+
.or_else(|| {
|
|
5013
|
+
rhs.strip_prefix('\'')
|
|
5014
|
+
.and_then(|value| value.strip_suffix('\''))
|
|
5015
|
+
})
|
|
5016
|
+
.unwrap_or(rhs);
|
|
5017
|
+
!rhs.is_empty()
|
|
5018
|
+
})
|
|
5019
|
+
})
|
|
5020
|
+
}
|
|
5064
5021
|
///
|
|
5065
5022
|
/// `run_comms_selftest`(`--comms`/`--gate comms`)。**纯 state-read,零 token**(MUST-NOT-13)。
|
|
5066
5023
|
#[cfg(test)]
|