@team-agent/installer 0.5.92 → 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.
@@ -549,9 +549,7 @@ pub mod lifecycle_port {
549
549
  /// E12 下沉纯函数:bare-shutdown socket 拆除决策。
550
550
  #[derive(Debug, Clone, PartialEq, Eq)]
551
551
  pub(crate) enum KillDecision {
552
- /// socket 独享(无 spare、无外来 session)→ 可整 server 拆除。
553
- KillServerExclusive,
554
- /// 有 spare(leader 锚/前缀)或非独享 → 逐 session kill,绝不 kill-server。
552
+ /// Ownership-gated cleanup always uses per-session kills; never whole-server teardown.
555
553
  KillIndividually {
556
554
  to_kill: Vec<crate::transport::SessionName>,
557
555
  spared: Vec<crate::transport::SessionName>,
@@ -560,8 +558,7 @@ pub mod lifecycle_port {
560
558
 
561
559
  ///
562
560
  /// E12 纯决策(单测下沉):spare = `anchor_sessions` ∪ `team-agent-leader-*` 前缀(并集,锚优先)。
563
- /// 全部 session 都不 spare 且非空 → `KillServerExclusive`(独享 socket 兜底);否则逐 session
564
- /// kill 非 spare 的(共享 socket / leader 在 → 绝不整 server 拆)。空 session 集 → 逐 kill(no-op)。
561
+ /// Always returns a per-session plan; ownership markers are checked by the caller.
565
562
  pub(crate) fn sessions_to_kill(
566
563
  sessions: &[crate::transport::SessionName],
567
564
  anchor_sessions: &std::collections::BTreeSet<String>,
@@ -572,12 +569,9 @@ pub mod lifecycle_port {
572
569
  };
573
570
  let spared: Vec<_> = sessions.iter().filter(|s| is_spared(s)).cloned().collect();
574
571
  let to_kill: Vec<_> = sessions.iter().filter(|s| !is_spared(s)).cloned().collect();
575
- // 独享 = 非空 + 无 spare(socket 上每个 session 都是要 kill 的我方 session)。
576
- if spared.is_empty() && !sessions.is_empty() {
577
- KillDecision::KillServerExclusive
578
- } else {
579
- KillDecision::KillIndividually { to_kill, spared }
580
- }
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 }
581
575
  }
582
576
 
583
577
  #[derive(Debug, Default)]
@@ -615,118 +609,178 @@ pub mod lifecycle_port {
615
609
  }
616
610
 
617
611
  fn bare_shutdown_socket_cleanup(
612
+ workspace: &Path,
618
613
  transport: &dyn crate::transport::Transport,
619
614
  state: &Value,
620
615
  event_log: &crate::event_log::EventLog,
621
616
  ) -> ShutdownSocketCleanup {
622
- // E12 (P0): the leader terminal lives on this socket by design. A bare shutdown must
623
- // NOT `kill-server` it away. spare = state-anchor sessions ∪ `team-agent-leader-*`
624
- // prefix sessions (union; cr E12 ①). kill_server only when the socket is exclusively
625
- // ours (no spare + no foreign session); shared socket → kill our sessions individually
626
- // (cr E12 ②). All spare derivation comes from ONE snapshot (list_targets + state) —
627
- // no independent ps/tmux re-derivation (N39).
628
- let pane_targets = transport.list_targets().unwrap_or_default();
629
- let sessions = socket_session_names_from_targets(&pane_targets);
630
- if !state_uses_external_leader(state) {
631
- return managed_leader_socket_cleanup(transport, state, &sessions, event_log);
632
- }
633
- let anchor_sessions = anchor_sessions_from_state(state, &pane_targets, event_log);
634
- match sessions_to_kill(&sessions, &anchor_sessions) {
635
- KillDecision::KillServerExclusive => {
636
- if state.get("tmux_socket_source").and_then(Value::as_str) == Some("leader_env") {
637
- let _ = event_log.write(
638
- "shutdown.kill_server_skipped_shared_socket",
639
- json!({
640
- "reason": "leader_env_tmux_socket",
641
- "spared_sessions": [],
642
- "killed_sessions": sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
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
- }
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);
665
639
  }
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
640
  }
691
641
  }
692
- KillDecision::KillIndividually { to_kill, spared } => {
693
- if !spared.is_empty() || to_kill.len() != sessions.len() {
694
- // shared socket / leader spared → never whole-server teardown.
695
- let _ = event_log.write(
696
- "shutdown.kill_server_skipped_shared_socket",
697
- json!({
698
- "spared_sessions": spared.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
699
- "killed_sessions": to_kill.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
700
- }),
701
- );
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()
702
649
  }
703
- let mut error = None;
704
- for session in &to_kill {
705
- let targets = vec![session.as_str().to_string()];
706
- if let Err(audit_error) = crate::kill_audit::pre_kill_audit(
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(
707
663
  event_log,
708
664
  transport,
709
- "shutdown.selected_session",
665
+ "shutdown.state_owned_session",
710
666
  crate::kill_audit::KILL_SESSION,
711
- &targets,
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",
712
674
  ) {
713
- error
714
- .get_or_insert_with(|| format!("pre-kill audit failed: {audit_error}"));
715
- continue;
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());
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());
720
679
  }
680
+ } else {
681
+ result.killed_sessions.push(session);
721
682
  }
722
683
  }
723
- ShutdownSocketCleanup {
724
- killed_sessions: to_kill,
725
- spared_sessions: spared,
726
- error,
684
+ SessionOwnership::Foreign => {
685
+ push_unique_session(&mut result.spared_sessions, session);
727
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());
690
+ }
691
+ SessionOwnership::Gone => {}
728
692
  }
729
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
730
784
  }
731
785
 
732
786
  #[cfg(test)]
@@ -762,10 +816,12 @@ pub mod lifecycle_port {
762
816
  leader_env: BTreeMap::new(),
763
817
  }]);
764
818
  let cleanup = bare_shutdown_socket_cleanup(
819
+ Path::new("/tmp/team-agent-test"),
765
820
  &transport,
766
821
  &json!({
767
822
  "is_external_leader": true,
768
823
  "tmux_socket_source": "leader_env",
824
+ "session_name": "team-audit-fail",
769
825
  }),
770
826
  &event_log,
771
827
  );
@@ -777,7 +833,7 @@ pub mod lifecycle_port {
777
833
  assert!(cleanup
778
834
  .error
779
835
  .as_deref()
780
- .is_some_and(|error| error.contains("pre-kill audit failed")));
836
+ .is_some_and(|error| error.contains("ownership is unknown")));
781
837
  let _ = std::fs::remove_dir_all(root);
782
838
  }
783
839
  }
@@ -786,106 +842,6 @@ pub mod lifecycle_port {
786
842
  crate::state::projection::state_is_external_leader(state)
787
843
  }
788
844
 
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
845
  fn owned_shutdown_endpoint(
890
846
  workspace: &Path,
891
847
  state: &Value,
@@ -970,40 +926,15 @@ pub mod lifecycle_port {
970
926
  );
971
927
  return cleanup;
972
928
  }
973
- if let Err(error) = crate::kill_audit::pre_kill_audit(
974
- event_log,
975
- transport,
976
- "shutdown.empty_owned_endpoint",
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
- }
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.
1000
933
  let _ = event_log.write(
1001
- "shutdown.owned_endpoint_cleanup",
934
+ "shutdown.owned_endpoint_cleanup_skipped",
1002
935
  json!({
1003
936
  "endpoint": endpoint.endpoint,
1004
- "removed_files": cleanup.removed_files.clone(),
1005
- "residual_files": cleanup.residual_files.clone(),
1006
- "error": cleanup.error.clone(),
937
+ "reason": "server_and_socket_cleanup_disabled_fail_closed",
1007
938
  }),
1008
939
  );
1009
940
  cleanup
@@ -1081,10 +1012,18 @@ pub mod lifecycle_port {
1081
1012
  root_pids.extend(pane_pids);
1082
1013
  root_pids.sort_unstable();
1083
1014
  root_pids.dedup();
1084
- let root_pgids = process_pgids(&root_pids, &protected, &entry_table);
1085
1015
  deadline.check("reap_process_tree")?;
1086
- reap_process_tree(&root_pids, &protected, &entry_table);
1087
- reap_process_groups(&root_pgids, &protected);
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
+
1088
1027
  let mut kill_error: Option<String> = None;
1089
1028
  let mut killed_sessions = Vec::new();
1090
1029
  let mut spared_sessions = Vec::new();
@@ -1100,57 +1039,111 @@ pub mod lifecycle_port {
1100
1039
  // session per `list_targets`. When managed-leader, switch to per-pane
1101
1040
  // cleanup: kill the workers individually and SPARE the leader's pane.
1102
1041
  //
1103
- // External leader (is_external_leader=true) keeps the unconditional
1104
- // kill_session — the team session is a disposable worker session in
1105
- // that topology, the leader pane lives elsewhere.
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.
1106
1045
  let leader_anchor_ids = collect_state_leader_anchor_pane_ids(&state);
1107
- let live_targets_now = transport.list_targets().unwrap_or_default();
1108
- let session_has_leader_anchor = live_targets_now.iter().any(|target| {
1109
- target.session.as_str() == session.as_str()
1110
- && leader_anchor_ids.contains(target.pane_id.as_str())
1111
- });
1112
- if crate::state::projection::state_is_managed_leader(&state)
1113
- && session_has_leader_anchor
1114
- {
1115
- let _ = event_log_write_session_spared(&run_workspace, session, &leader_anchor_ids);
1116
- push_unique_session(&mut spared_sessions, session.clone());
1117
- let worker_panes = collect_session_worker_panes(
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,
1118
1057
  &state,
1119
- session.as_str(),
1120
- &leader_anchor_ids,
1058
+ transport,
1059
+ session,
1121
1060
  &live_targets_now,
1122
- );
1123
- for pane in &worker_panes {
1124
- if let Err(error) = transport.kill_pane(pane) {
1125
- if !tmux_absent_error(&error.to_string()) {
1126
- kill_error.get_or_insert_with(|| error.to_string());
1127
- }
1128
- }
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());
1129
1069
  }
1130
- let _ =
1131
- crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
1132
1070
  } else {
1133
- let targets = vec![session.as_str().to_string()];
1134
- let event_log = crate::event_log::EventLog::new(&run_workspace);
1135
- if let Err(error) = crate::kill_audit::pre_kill_audit(
1136
- &event_log,
1137
- transport,
1138
- "shutdown.team_session",
1139
- crate::kill_audit::KILL_SESSION,
1140
- &targets,
1141
- ) {
1142
- kill_error = Some(format!("pre-kill audit failed: {error}"));
1143
- } else if let Err(error) = transport.kill_session(session) {
1144
- if tmux_absent_error(&error.to_string()) {
1145
- push_unique_session(&mut killed_sessions, session.clone());
1146
- } else {
1147
- kill_error = Some(error.to_string());
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
+ }
1148
1111
  }
1112
+ let _ = crate::lifecycle::display::close_team_display_backends(
1113
+ &run_workspace,
1114
+ session,
1115
+ );
1149
1116
  } else {
1150
- push_unique_session(&mut killed_sessions, session.clone());
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
+ );
1151
1146
  }
1152
- let _ =
1153
- crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
1154
1147
  }
1155
1148
  }
1156
1149
  deadline.check("reap_workspace_residuals")?;
@@ -1158,7 +1151,6 @@ pub mod lifecycle_port {
1158
1151
  &run_workspace,
1159
1152
  &state,
1160
1153
  &root_pids,
1161
- &root_pgids,
1162
1154
  transport,
1163
1155
  reap_scope,
1164
1156
  &mut probe_degraded,
@@ -1166,7 +1158,7 @@ pub mod lifecycle_port {
1166
1158
  if team.is_none() {
1167
1159
  deadline.check("shared_socket_cleanup")?;
1168
1160
  let event_log = crate::event_log::EventLog::new(&run_workspace);
1169
- let cleanup = bare_shutdown_socket_cleanup(transport, &state, &event_log);
1161
+ let cleanup = bare_shutdown_socket_cleanup(&run_workspace, transport, &state, &event_log);
1170
1162
  for session in cleanup.killed_sessions {
1171
1163
  push_unique_session(&mut killed_sessions, session);
1172
1164
  }
@@ -1175,6 +1167,7 @@ pub mod lifecycle_port {
1175
1167
  kill_error.get_or_insert(error);
1176
1168
  }
1177
1169
  }
1170
+ spared_sessions.retain(|session| transport.has_session(session).unwrap_or(true));
1178
1171
  deadline.check("session_residuals")?;
1179
1172
  let (session_residuals, session_residual_error) = session_residuals_after_reap_many(
1180
1173
  transport,
@@ -1208,7 +1201,6 @@ pub mod lifecycle_port {
1208
1201
  &run_workspace,
1209
1202
  &state,
1210
1203
  &root_pids,
1211
- &root_pgids,
1212
1204
  &protected,
1213
1205
  reap_scope,
1214
1206
  &verify_table,
@@ -1838,12 +1830,25 @@ pub mod lifecycle_port {
1838
1830
  /// pids; Gap 37 escalation order TERM -> grace -> KILL preserved), then a single
1839
1831
  /// bounded wait for the whole union. kill/wait sets derive from the SAME snapshot
1840
1832
  /// as the protected set (N39).
1841
- fn reap_process_tree(root_pids: &[u32], protected: &ShutdownProtection, table: &[ProcessInfo]) {
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
+ ) {
1842
1842
  let mut pids = Vec::new();
1843
1843
  let mut seen = std::collections::BTreeSet::new();
1844
+ let spawn_cwds = state_spawn_cwds(state, scope);
1844
1845
  for root in root_pids {
1845
1846
  for pid in process_tree_from_table(*root, table) {
1846
- if !protected.contains_pid(pid) && seen.insert(pid) {
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) {
1847
1852
  pids.push(pid);
1848
1853
  }
1849
1854
  }
@@ -1851,50 +1856,58 @@ pub mod lifecycle_port {
1851
1856
  if pids.is_empty() {
1852
1857
  return;
1853
1858
  }
1854
- for pid in pids.iter().rev() {
1855
- let _ = crate::platform::process::terminate_pid(
1856
- *pid,
1857
- crate::platform::process::SignalKind::TerminateGraceful,
1858
- );
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;
1859
1880
  }
1860
- std::thread::sleep(std::time::Duration::from_millis(150));
1861
1881
  for pid in pids.iter().rev() {
1862
1882
  let _ = crate::platform::process::terminate_pid(
1863
1883
  *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
1884
  crate::platform::process::SignalKind::TerminateGraceful,
1886
1885
  );
1887
1886
  }
1888
1887
  std::thread::sleep(std::time::Duration::from_millis(150));
1889
- for pgid in pgids {
1890
- if *pgid <= 1 || protected.contains_pgid(*pgid) {
1891
- continue;
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
+ }
1892
1908
  }
1893
- let _ = crate::platform::process::terminate_group(
1894
- *pgid,
1895
- crate::platform::process::SignalKind::TerminateForce,
1896
- );
1897
1909
  }
1910
+ wait_for_processes_gone(&pids, std::time::Duration::from_secs(1));
1898
1911
  }
1899
1912
 
1900
1913
  /// PERF-6 C-①-2 + C-②-5: every residual round fetches ONE fresh snapshot (reap
@@ -1904,7 +1917,6 @@ pub mod lifecycle_port {
1904
1917
  workspace: &Path,
1905
1918
  state: &Value,
1906
1919
  root_pids: &[u32],
1907
- root_pgids: &[u32],
1908
1920
  transport: &dyn crate::transport::Transport,
1909
1921
  scope: ShutdownReapScope,
1910
1922
  probe_degraded: &mut bool,
@@ -1917,7 +1929,6 @@ pub mod lifecycle_port {
1917
1929
  workspace,
1918
1930
  state,
1919
1931
  root_pids,
1920
- root_pgids,
1921
1932
  &protected,
1922
1933
  scope,
1923
1934
  &round_table,
@@ -1929,12 +1940,15 @@ pub mod lifecycle_port {
1929
1940
  .iter()
1930
1941
  .map(|process| process.pid)
1931
1942
  .collect::<Vec<_>>();
1932
- reap_process_tree(&residual_pids, &protected, &round_table);
1933
- let pgids = residuals
1934
- .iter()
1935
- .filter_map(|process| process.pgid)
1936
- .collect::<Vec<_>>();
1937
- reap_process_groups(&pgids, &protected);
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
+ );
1938
1952
  std::thread::sleep(std::time::Duration::from_millis(100));
1939
1953
  }
1940
1954
  }
@@ -2116,9 +2130,9 @@ pub mod lifecycle_port {
2116
2130
  if leader_anchor_ids.contains(pane_id) {
2117
2131
  continue;
2118
2132
  }
2119
- let in_live = live_targets
2120
- .iter()
2121
- .any(|info| info.pane_id.as_str() == pane_id);
2133
+ let in_live = live_targets.iter().any(|info| {
2134
+ info.pane_id.as_str() == pane_id && info.session.as_str() == session
2135
+ });
2122
2136
  if in_live && seen.insert(pane_id.to_string()) {
2123
2137
  out.push(crate::transport::PaneId::new(pane_id));
2124
2138
  continue;
@@ -2386,44 +2400,16 @@ pub mod lifecycle_port {
2386
2400
  }
2387
2401
  }
2388
2402
 
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
2403
  fn process_residuals(
2417
2404
  workspace: &Path,
2418
2405
  state: &Value,
2419
2406
  root_pids: &[u32],
2420
- root_pgids: &[u32],
2421
2407
  protected: &ShutdownProtection,
2422
2408
  scope: ShutdownReapScope,
2423
2409
  table: &[ProcessInfo],
2424
2410
  ) -> Vec<Value> {
2425
2411
  let mut residuals = matched_processes(
2426
- workspace, state, root_pids, root_pgids, protected, scope, table,
2412
+ workspace, state, root_pids, protected, scope, table,
2427
2413
  );
2428
2414
  let mut seen = residuals
2429
2415
  .iter()
@@ -2461,7 +2447,6 @@ pub mod lifecycle_port {
2461
2447
  workspace: &Path,
2462
2448
  state: &Value,
2463
2449
  root_pids: &[u32],
2464
- root_pgids: &[u32],
2465
2450
  protected: &ShutdownProtection,
2466
2451
  scope: ShutdownReapScope,
2467
2452
  table: &[ProcessInfo],
@@ -2471,33 +2456,14 @@ pub mod lifecycle_port {
2471
2456
  .flat_map(|pid| process_tree_from_table(*pid, table))
2472
2457
  .filter(|pid| !protected.contains_pid(*pid))
2473
2458
  .collect::<std::collections::BTreeSet<_>>();
2474
- let root_pgids = root_pgids
2475
- .iter()
2476
- .copied()
2477
- .collect::<std::collections::BTreeSet<_>>();
2478
2459
  let spawn_cwds = state_spawn_cwds(state, scope);
2479
- let workspace_text = workspace.to_string_lossy().to_string();
2480
- let mut cwd_probe_budget = 3_usize;
2481
- let mut out = Vec::new();
2482
- for process in table {
2483
- if protected.contains_pid(process.pid) {
2484
- continue;
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
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()
2501
2467
  }
2502
2468
 
2503
2469
  fn process_tree_from_table(root_pid: u32, table: &[ProcessInfo]) -> Vec<u32> {
@@ -2550,59 +2516,21 @@ pub mod lifecycle_port {
2550
2516
 
2551
2517
  fn process_matches_workspace(
2552
2518
  process: &ProcessInfo,
2553
- workspace_text: &str,
2554
- spawn_cwds: &[PathBuf],
2555
- cwd_probe_budget: &mut usize,
2519
+ workspace: &Path,
2520
+ _spawn_cwds: &[PathBuf],
2556
2521
  ) -> bool {
2557
- let command = process.command.as_str();
2558
- if command.contains("mcp-server")
2559
- && command.contains("--workspace")
2560
- && command.contains(workspace_text)
2561
- {
2562
- return true;
2563
- }
2564
- if command.contains(workspace_text) {
2565
- return true;
2566
- }
2567
- if spawn_cwds.is_empty() || *cwd_probe_budget == 0 {
2568
- return false;
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))
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
+ })
2606
2534
  }
2607
2535
 
2608
2536
  fn path_is_under(path: &Path, root: &Path) -> bool {
@@ -4488,6 +4416,7 @@ pub mod lifecycle_port {
4488
4416
  match status {
4489
4417
  crate::coordinator::StopOutcome::Missing => "missing",
4490
4418
  crate::coordinator::StopOutcome::InvalidPidRemoved => "invalid_pid_removed",
4419
+ crate::coordinator::StopOutcome::NotOwned => "not_owned",
4491
4420
  crate::coordinator::StopOutcome::KillFailed => "kill_failed",
4492
4421
  crate::coordinator::StopOutcome::Stopped => "stopped",
4493
4422
  }