@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.
@@ -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
- /// socket 独享(无 spare、无外来 session)→ 可整 server 拆除。
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
- /// 全部 session 都不 spare 且非空 → `KillServerExclusive`(独享 socket 兜底);否则逐 session
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
- // 独享 = 非空 + 无 spare(socket 上每个 session 都是要 kill 的我方 session)。
575
- if spared.is_empty() && !sessions.is_empty() {
576
- KillDecision::KillServerExclusive
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
- // E12 (P0): the leader terminal lives on this socket by design. A bare shutdown must
622
- // NOT `kill-server` it away. spare = state-anchor sessions ∪ `team-agent-leader-*`
623
- // prefix sessions (union; cr E12 ①). kill_server only when the socket is exclusively
624
- // ours (no spare + no foreign session); shared socket → kill our sessions individually
625
- // (cr E12 ②). All spare derivation comes from ONE snapshot (list_targets + state) —
626
- // no independent ps/tmux re-derivation (N39).
627
- let pane_targets = transport.list_targets().unwrap_or_default();
628
- let sessions = socket_session_names_from_targets(&pane_targets);
629
- if !state_uses_external_leader(state) {
630
- return managed_leader_socket_cleanup(transport, state, &sessions, event_log);
631
- }
632
- let anchor_sessions = anchor_sessions_from_state(state, &pane_targets, event_log);
633
- match sessions_to_kill(&sessions, &anchor_sessions) {
634
- KillDecision::KillServerExclusive => {
635
- if state.get("tmux_socket_source").and_then(Value::as_str) == Some("leader_env") {
636
- let _ = event_log.write(
637
- "shutdown.kill_server_skipped_shared_socket",
638
- json!({
639
- "reason": "leader_env_tmux_socket",
640
- "spared_sessions": [],
641
- "killed_sessions": sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
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
- KillDecision::KillIndividually { to_kill, spared } => {
692
- if !spared.is_empty() || to_kill.len() != sessions.len() {
693
- // shared socket / leader spared → never whole-server teardown.
694
- let _ = event_log.write(
695
- "shutdown.kill_server_skipped_shared_socket",
696
- json!({
697
- "spared_sessions": spared.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
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
- let mut error = None;
703
- for session in &to_kill {
704
- let targets = vec![session.as_str().to_string()];
705
- 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(
706
663
  event_log,
707
664
  transport,
708
- "shutdown.selected_session",
665
+ "shutdown.state_owned_session",
709
666
  crate::kill_audit::KILL_SESSION,
710
- &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",
711
674
  ) {
712
- error
713
- .get_or_insert_with(|| format!("pre-kill audit failed: {audit_error}"));
714
- continue;
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
- ShutdownSocketCleanup {
723
- killed_sessions: to_kill,
724
- spared_sessions: spared,
725
- error,
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("pre-kill audit failed")));
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
- if let Err(error) = crate::kill_audit::pre_kill_audit(
973
- event_log,
974
- transport,
975
- "shutdown.empty_owned_endpoint",
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.owned_endpoint_cleanup",
934
+ "shutdown.owned_endpoint_cleanup_skipped",
1001
935
  json!({
1002
936
  "endpoint": endpoint.endpoint,
1003
- "removed_files": cleanup.removed_files.clone(),
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
- reap_process_tree(&root_pids, &protected, &entry_table);
1086
- 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
+
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 leader (is_external_leader=true) keeps the unconditional
1103
- // kill_session — the team session is a disposable worker session in
1104
- // 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.
1105
1045
  let leader_anchor_ids = collect_state_leader_anchor_pane_ids(&state);
1106
- let live_targets_now = transport.list_targets().unwrap_or_default();
1107
- let session_has_leader_anchor = live_targets_now.iter().any(|target| {
1108
- target.session.as_str() == session.as_str()
1109
- && leader_anchor_ids.contains(target.pane_id.as_str())
1110
- });
1111
- if crate::state::projection::state_is_managed_leader(&state)
1112
- && session_has_leader_anchor
1113
- {
1114
- let _ = event_log_write_session_spared(&run_workspace, session, &leader_anchor_ids);
1115
- push_unique_session(&mut spared_sessions, session.clone());
1116
- 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,
1117
1057
  &state,
1118
- session.as_str(),
1119
- &leader_anchor_ids,
1058
+ transport,
1059
+ session,
1120
1060
  &live_targets_now,
1121
- );
1122
- for pane in &worker_panes {
1123
- if let Err(error) = transport.kill_pane(pane) {
1124
- if !tmux_absent_error(&error.to_string()) {
1125
- kill_error.get_or_insert_with(|| error.to_string());
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 targets = vec![session.as_str().to_string()];
1133
- let event_log = crate::event_log::EventLog::new(&run_workspace);
1134
- if let Err(error) = crate::kill_audit::pre_kill_audit(
1135
- &event_log,
1136
- transport,
1137
- "shutdown.team_session",
1138
- crate::kill_audit::KILL_SESSION,
1139
- &targets,
1140
- ) {
1141
- kill_error = Some(format!("pre-kill audit failed: {error}"));
1142
- } else if let Err(error) = transport.kill_session(session) {
1143
- if tmux_absent_error(&error.to_string()) {
1144
- push_unique_session(&mut killed_sessions, session.clone());
1145
- } else {
1146
- 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
+ }
1147
1111
  }
1112
+ let _ = crate::lifecycle::display::close_team_display_backends(
1113
+ &run_workspace,
1114
+ session,
1115
+ );
1148
1116
  } else {
1149
- 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
+ );
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(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
+ ) {
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
- 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) {
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
- for pid in pids.iter().rev() {
1854
- let _ = crate::platform::process::terminate_pid(
1855
- *pid,
1856
- crate::platform::process::SignalKind::TerminateGraceful,
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
- for pgid in pgids {
1889
- if *pgid <= 1 || protected.contains_pgid(*pgid) {
1890
- 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
+ }
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(&residual_pids, &protected, &round_table);
1932
- let pgids = residuals
1933
- .iter()
1934
- .filter_map(|process| process.pgid)
1935
- .collect::<Vec<_>>();
1936
- 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
+ );
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
- .iter()
2120
- .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
+ });
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, root_pgids, protected, scope, table,
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
- let workspace_text = workspace.to_string_lossy().to_string();
2479
- let mut cwd_probe_budget = 3_usize;
2480
- let mut out = Vec::new();
2481
- for process in table {
2482
- if protected.contains_pid(process.pid) {
2483
- continue;
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
- workspace_text: &str,
2553
- spawn_cwds: &[PathBuf],
2554
- cwd_probe_budget: &mut usize,
2519
+ workspace: &Path,
2520
+ _spawn_cwds: &[PathBuf],
2555
2521
  ) -> bool {
2556
- let command = process.command.as_str();
2557
- if command.contains("mcp-server")
2558
- && command.contains("--workspace")
2559
- && command.contains(workspace_text)
2560
- {
2561
- return true;
2562
- }
2563
- if command.contains(workspace_text) {
2564
- return true;
2565
- }
2566
- if spawn_cwds.is_empty() || *cwd_probe_budget == 0 {
2567
- return false;
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
- if line.contains("OPENAI_API_KEY=") || line.contains("ANTHROPIC_API_KEY=") {
5054
- let rel = path.strip_prefix(root).unwrap_or(path);
5055
- findings.push(json!({
5056
- "path": rel.to_string_lossy().to_string(),
5057
- "line": idx.saturating_add(1),
5058
- "rule": "api_key_assignment",
5059
- "match_excerpt": line.trim(),
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)]