@team-agent/installer 0.5.92 → 0.5.97

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