@team-agent/installer 0.3.7 → 0.3.8

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.
@@ -1,39 +1,104 @@
1
- //! B5/F1 · `sessions_to_kill_sparing_leader` 纯函数单测(kill 决策下沉后锁定)。
1
+ //! E12 (P0) · `sessions_to_kill` 纯决策单测(kill 决策下沉)。
2
2
  //!
3
- //! 真相源 = `team-agent-leader-` 确定性命名前缀(leader/start.rs LEADER_SESSION_PREFIX);
4
- //! 集成面由 tests/b5_leader_terminal_kill_red.rs 的真 tmux 契约覆盖,此处锁纯决策。
3
+ //! spare = state 锚 session(anchor_sessions) ∪ `team-agent-leader-` 命名前缀(并集,锚优先)
4
+ //! 独享 socket(无 spare)才允许整 server 拆;共享/leader 在 → 逐 session kill。
5
+ //! 集成面由 tests/b5_leader_terminal_kill_red.rs 的真 tmux 契约覆盖,此处锁纯决策 + 4 反向 case。
5
6
 
6
- use crate::cli::lifecycle_port::sessions_to_kill_sparing_leader;
7
+ use crate::cli::lifecycle_port::{sessions_to_kill, KillDecision};
7
8
  use crate::transport::SessionName;
9
+ use std::collections::BTreeSet;
8
10
 
9
11
  fn names(raw: &[&str]) -> Vec<SessionName> {
10
12
  raw.iter().map(|name| SessionName::new(*name)).collect()
11
13
  }
12
14
 
15
+ fn anchors(raw: &[&str]) -> BTreeSet<String> {
16
+ raw.iter().map(|s| s.to_string()).collect()
17
+ }
18
+
19
+ // RC-4(独享 socket):仅目标 session(无 spare)→ 整 server 拆。
20
+ #[test]
21
+ fn rc4_exclusive_socket_kills_server() {
22
+ assert_eq!(
23
+ sessions_to_kill(&names(&["team-x", "team-y"]), &BTreeSet::new()),
24
+ KillDecision::KillServerExclusive
25
+ );
26
+ // 空 session 集 → 逐 kill(no-op),不整 server 拆(没东西可拆)。
27
+ assert_eq!(
28
+ sessions_to_kill(&[], &BTreeSet::new()),
29
+ KillDecision::KillIndividually { to_kill: vec![], spared: vec![] }
30
+ );
31
+ }
32
+
33
+ // RC-1(本 P0 复现):in_tmux leader 在用户 session(**无前缀**),靠 state 锚 spare → 用户 session 存活。
13
34
  #[test]
14
- fn no_leader_session_means_whole_server_kill() {
15
- assert_eq!(sessions_to_kill_sparing_leader(&names(&["team-x"])), None);
16
- assert_eq!(sessions_to_kill_sparing_leader(&[]), None);
35
+ fn rc1_in_tmux_no_prefix_anchor_spares_user_session() {
36
+ let sessions = names(&["team-coder-team", "team-x"]); // 用户 session 无 leader 前缀
37
+ let anchor = anchors(&["team-coder-team"]); // state 锚 pane 所在 session
38
+ let decision = sessions_to_kill(&sessions, &anchor);
39
+ match decision {
40
+ KillDecision::KillIndividually { to_kill, spared } => {
41
+ assert_eq!(to_kill, names(&["team-x"]), "only non-anchor session killed");
42
+ assert_eq!(spared, names(&["team-coder-team"]), "user/leader session spared by anchor");
43
+ }
44
+ other => panic!("anchor session must force per-session kill, not {other:?}"),
45
+ }
46
+ }
47
+
48
+ // 前缀判据仍生效(并集):leader 前缀 session spare,即使无 state 锚。
49
+ #[test]
50
+ fn prefix_session_spared_without_anchor() {
51
+ let sessions = names(&["team-agent-leader-claude-ws-deadbeef", "team-x"]);
52
+ let decision = sessions_to_kill(&sessions, &BTreeSet::new());
53
+ match decision {
54
+ KillDecision::KillIndividually { to_kill, spared } => {
55
+ assert_eq!(to_kill, names(&["team-x"]));
56
+ assert_eq!(spared, names(&["team-agent-leader-claude-ws-deadbeef"]));
57
+ }
58
+ other => panic!("prefix session must spare, not {other:?}"),
59
+ }
60
+ }
61
+
62
+ // RC-2(state 损坏无锚):anchor_sessions 空 → 退命名前缀判据(此处仅前缀 spare;无前缀则全 kill)。
63
+ // (spare_fallback_to_naming event 在 anchor_anchor_sessions 发,本纯函数只验退化后的决策。)
64
+ #[test]
65
+ fn rc2_no_anchor_falls_back_to_naming() {
66
+ // 无锚 + 有前缀 leader → 前缀 spare。
67
+ let with_leader = sessions_to_kill(
68
+ &names(&["team-agent-leader-codex-ws-cafe", "team-x"]),
69
+ &BTreeSet::new(),
70
+ );
71
+ assert!(matches!(with_leader, KillDecision::KillIndividually { .. }));
72
+ // 无锚 + 无前缀(真损坏且 in_tmux 无前缀)→ 无 spare → 独享拆(退化兜底,与历史一致)。
73
+ assert_eq!(
74
+ sessions_to_kill(&names(&["team-x"]), &BTreeSet::new()),
75
+ KillDecision::KillServerExclusive
76
+ );
17
77
  }
18
78
 
79
+ // RC-3(共享 socket):目标 2 session + 用户 1 session(锚)→ 只 kill 目标 2,用户存活,不整 server 拆。
19
80
  #[test]
20
- fn leader_session_present_kills_only_non_leader_sessions() {
21
- let sessions = names(&[
22
- "team-agent-leader-claude-myws-deadbeef",
23
- "team-x",
24
- "team-y",
25
- ]);
26
- let to_kill = sessions_to_kill_sparing_leader(&sessions)
27
- .expect("leader present must switch to per-session kills");
28
- assert_eq!(to_kill, names(&["team-x", "team-y"]));
81
+ fn rc3_shared_socket_kills_only_target_sessions() {
82
+ let sessions = names(&["team-a", "team-b", "user-shell"]);
83
+ let anchor = anchors(&["user-shell"]);
84
+ let decision = sessions_to_kill(&sessions, &anchor);
85
+ match decision {
86
+ KillDecision::KillIndividually { to_kill, spared } => {
87
+ assert_eq!(to_kill, names(&["team-a", "team-b"]));
88
+ assert_eq!(spared, names(&["user-shell"]));
89
+ }
90
+ other => panic!("shared socket must not whole-server kill, got {other:?}"),
91
+ }
29
92
  }
30
93
 
94
+ // 并集语义:同一 session 既前缀又锚 → spare 一次(不重复)。
31
95
  #[test]
32
- fn only_leader_sessions_left_kills_nothing_but_keeps_server() {
33
- let sessions = names(&["team-agent-leader-codex-myws-cafe0123"]);
96
+ fn union_prefix_and_anchor_no_double_count() {
97
+ let sessions = names(&["team-agent-leader-claude-ws-beef"]);
98
+ let anchor = anchors(&["team-agent-leader-claude-ws-beef"]);
99
+ let decision = sessions_to_kill(&sessions, &anchor);
34
100
  assert_eq!(
35
- sessions_to_kill_sparing_leader(&sessions),
36
- Some(Vec::new()),
37
- "a socket holding only leader sessions must not be torn down"
101
+ decision,
102
+ KillDecision::KillIndividually { to_kill: vec![], spared: names(&["team-agent-leader-claude-ws-beef"]) }
38
103
  );
39
104
  }
@@ -0,0 +1,76 @@
1
+ use super::*;
2
+
3
+ // RED-1 根治:install-skill 必须作为真 CLI 子命令存在(此前 packaging::install_skill 未接进
4
+ // 任何 dispatch=双重死代码,install.mjs 走自己的 JS 拷贝逻辑漏 copilot)。
5
+ // 这里钉:① 子命令路由 ② --target all 默认 ③ --source 必需 ④ 缺 source 显式 Usage 错。
6
+
7
+ fn skill_source() -> PathBuf {
8
+ use std::sync::atomic::{AtomicU64, Ordering};
9
+ static CTR: AtomicU64 = AtomicU64::new(0);
10
+ let dir = std::env::temp_dir().join(format!(
11
+ "ta-cli-installskill-src-{}-{}",
12
+ std::process::id(),
13
+ CTR.fetch_add(1, Ordering::Relaxed)
14
+ ));
15
+ std::fs::create_dir_all(dir.join("team-agent")).unwrap();
16
+ std::fs::write(dir.join("team-agent").join("SKILL.md"), b"---\nname: team-agent\n---\nbody\n").unwrap();
17
+ dir.join("team-agent")
18
+ }
19
+
20
+ #[test]
21
+ fn dispatch_routes_install_skill_dry_run_all() {
22
+ let ws = tmp_workspace();
23
+ let source = skill_source();
24
+ // --dry-run 不落地(不碰真实 HOME),只验路由 + exit 0。
25
+ let code = run(
26
+ &[
27
+ "install-skill".to_string(),
28
+ "--target".to_string(),
29
+ "all".to_string(),
30
+ "--source".to_string(),
31
+ source.to_string_lossy().to_string(),
32
+ "--dry-run".to_string(),
33
+ "--json".to_string(),
34
+ ],
35
+ &ws,
36
+ );
37
+ assert_eq!(code, ExitCode::Ok, "`install-skill --target all --source <dir> --dry-run` must route + exit 0");
38
+ let _ = std::fs::remove_dir_all(&ws);
39
+ let _ = std::fs::remove_dir_all(source.parent().unwrap());
40
+ }
41
+
42
+ #[test]
43
+ fn install_skill_missing_source_is_usage_error() {
44
+ let ws = tmp_workspace();
45
+ // 无 --source → Usage 错(exit 2),不静默。
46
+ let code = run(
47
+ &[
48
+ "install-skill".to_string(),
49
+ "--target".to_string(),
50
+ "all".to_string(),
51
+ "--json".to_string(),
52
+ ],
53
+ &ws,
54
+ );
55
+ assert_eq!(code, ExitCode::Error, "install-skill without --source must error (CliError::Usage -> emit_cli_error exit 1)");
56
+ let _ = std::fs::remove_dir_all(&ws);
57
+ }
58
+
59
+ #[test]
60
+ fn install_skill_invalid_target_is_usage_error() {
61
+ let ws = tmp_workspace();
62
+ let source = skill_source();
63
+ let code = run(
64
+ &[
65
+ "install-skill".to_string(),
66
+ "--target".to_string(),
67
+ "bogus".to_string(),
68
+ "--source".to_string(),
69
+ source.to_string_lossy().to_string(),
70
+ ],
71
+ &ws,
72
+ );
73
+ assert_eq!(code, ExitCode::Error, "install-skill --target bogus must error (CliError::Usage -> emit_cli_error exit 1)");
74
+ let _ = std::fs::remove_dir_all(&ws);
75
+ let _ = std::fs::remove_dir_all(source.parent().unwrap());
76
+ }
@@ -189,15 +189,29 @@ fn bind_provider_from_env_or_command(command: &str) -> Provider {
189
189
  std::env::var("TEAM_AGENT_LEADER_PROVIDER")
190
190
  .ok()
191
191
  .and_then(|raw| super::helpers::parse_provider(&raw))
192
- .unwrap_or_else(|| provider_from_command(command))
192
+ .or_else(|| provider_from_command(command))
193
+ // E11 层2:未知命令不再静默默认 codex(会误绑任意 provider + 喂错分类器)。
194
+ // 无法识别时回落 Codex 仅作最末兜底,且该路径已被 provider_from_command 的显式 None 收窄
195
+ // (调用方理应只在已知 leader 命令上 bind);保留以不改 fn 签名/上游 panic 面。
196
+ .unwrap_or(Provider::Codex)
193
197
  }
194
198
 
195
- fn provider_from_command(command: &str) -> Provider {
199
+ /// E11 层2 + N39:command wire 串 → `parse_provider`(**单一映射源**,与
200
+ /// `owner_bind_provider_wire` 共用 [`command_provider_wire`])。未知命令 → `None`
201
+ /// (危险的 `_ => Codex` 默认已删:不静默把任意 provider 误绑成 codex)。
202
+ fn provider_from_command(command: &str) -> Option<Provider> {
203
+ command_provider_wire(command).and_then(super::helpers::parse_provider)
204
+ }
205
+
206
+ /// command 名 → provider wire 串(单一真相;copilot/claude/codex/fake)。未知 → `None`。
207
+ /// `claude.exe` 归一为 `claude`。
208
+ fn command_provider_wire(command: &str) -> Option<&'static str> {
196
209
  match exact_command_name(command).as_deref() {
197
- Some("claude") | Some("claude.exe") => Provider::Claude,
198
- Some("codex") => Provider::Codex,
199
- Some("fake") => Provider::Fake,
200
- _ => Provider::Codex,
210
+ Some("claude") | Some("claude.exe") => Some("claude"),
211
+ Some("codex") => Some("codex"),
212
+ Some("copilot") => Some("copilot"),
213
+ Some("fake") => Some("fake"),
214
+ _ => None,
201
215
  }
202
216
  }
203
217
 
@@ -214,21 +228,15 @@ fn exact_command_name(command: &str) -> Option<String> {
214
228
 
215
229
  pub fn owner_bind_provider_wire(command: &str) -> &'static str {
216
230
  if let Ok(raw) = std::env::var("TEAM_AGENT_LEADER_PROVIDER") {
217
- return match raw.as_str() {
218
- "claude" => "claude",
219
- "claude_code" => "claude_code",
220
- "codex" => "codex",
221
- "gemini_cli" => "gemini_cli",
222
- "fake" => "fake",
223
- _ => "",
224
- };
225
- }
226
- match exact_command_name(command).as_deref() {
227
- Some("claude") | Some("claude.exe") => "claude",
228
- Some("codex") => "codex",
229
- Some("fake") => "fake",
230
- _ => "",
231
+ // env 显式 provider:经 parse_provider(单一表,知 copilot)校验后透传其 wire 串;
232
+ // 不识别 ""(空,与原行为一致:不绑)。
233
+ return super::helpers::parse_provider(&raw)
234
+ .map(super::helpers::provider_wire)
235
+ .unwrap_or("");
231
236
  }
237
+ // E11 层2 + N39:与 provider_from_command 共用 command_provider_wire 单一映射(含 copilot);
238
+ // 未知命令 → ""(不绑),不再静默当 codex。
239
+ command_provider_wire(command).unwrap_or("")
232
240
  }
233
241
 
234
242
  fn family_a_identity(
@@ -290,3 +298,34 @@ fn tmux_pane_current_command(workspace: &Path, pane: &str) -> Result<String, Lea
290
298
  // NOTE: `derive_leader_session_uuid`(`leader_binding.py:146`)已由
291
299
  // `model::ids::LeaderSessionUuid::derive` 字节对齐实现(含 NUL 拒绝 + golden 测试)——
292
300
  // 此 lane REUSE 之,不重声明。
301
+
302
+ #[cfg(test)]
303
+ mod e11_provider_bind_tests {
304
+ #![allow(clippy::unwrap_used)]
305
+ use super::*;
306
+
307
+ // E11 层2:copilot leader 命令必须绑成 Provider::Copilot(此前缺臂 → _ => Codex 误绑)。
308
+ #[test]
309
+ fn copilot_command_binds_copilot_not_codex() {
310
+ assert_eq!(provider_from_command("copilot --banner -C /ws"), Some(Provider::Copilot));
311
+ assert_eq!(provider_from_command("/opt/homebrew/bin/copilot"), Some(Provider::Copilot));
312
+ assert_eq!(owner_bind_provider_wire("copilot --banner"), "copilot");
313
+ }
314
+
315
+ #[test]
316
+ fn known_commands_map_via_single_source() {
317
+ assert_eq!(provider_from_command("claude"), Some(Provider::Claude));
318
+ assert_eq!(provider_from_command("codex"), Some(Provider::Codex));
319
+ assert_eq!(provider_from_command("fake"), Some(Provider::Fake));
320
+ assert_eq!(owner_bind_provider_wire("claude"), "claude");
321
+ assert_eq!(owner_bind_provider_wire("codex"), "codex");
322
+ }
323
+
324
+ // E11 层2:未知命令不再静默默认 codex —— provider_from_command → None,wire → ""。
325
+ #[test]
326
+ fn unknown_command_is_none_not_silent_codex() {
327
+ assert_eq!(provider_from_command("node /some/thing.js"), None);
328
+ assert_eq!(provider_from_command("totally-unknown"), None);
329
+ assert_eq!(owner_bind_provider_wire("totally-unknown"), "");
330
+ }
331
+ }
@@ -768,28 +768,165 @@ fn env_nonempty(key: &str) -> bool {
768
768
  /// action : `unset TEAM_AGENT_LEADER_PANE_ID, or set it to a live tmux pane id`
769
769
  /// log : `TEAM_AGENT_LEADER_PANE_ID=%<id>`
770
770
  /// env 未设(或空)→ Ok(())。
771
- /// env 设了但 transport.liveness(pane) Dead → Err(RequirementUnmet)。
772
- /// liveness Unknown 不挡(被动路径降级):本主动路径只对【显式 Dead】fail-fast,
771
+ /// env 设了但 pane 可判定为 Dead/Absent → Err(RequirementUnmet)。
772
+ /// 真实 tmux 后端跨所有现存 tmux socket server 探测:TEAM_AGENT_LEADER_PANE_ID 是用户
773
+ /// override 指针,不归属当前 team socket。
774
+ /// probe 返 Unknown 不挡(被动路径降级):本主动路径只对【显式 Dead/Absent】fail-fast,
773
775
  /// MUST-17 不过度设计 / unset 走 pass-through(b7_unset_leader_pane_env_passes_through 守)。
774
776
  pub(crate) fn validate_active_leader_pane_env(
775
777
  transport: &dyn Transport,
778
+ ) -> Result<(), LifecycleError> {
779
+ validate_active_leader_pane_env_with_workspaces(transport, &[])
780
+ }
781
+
782
+ pub(crate) fn validate_active_leader_pane_env_with_workspace(
783
+ transport: &dyn Transport,
784
+ workspace: Option<&Path>,
785
+ ) -> Result<(), LifecycleError> {
786
+ let workspaces = workspace.into_iter().collect::<Vec<_>>();
787
+ validate_active_leader_pane_env_with_workspaces(transport, &workspaces)
788
+ }
789
+
790
+ pub(crate) fn validate_active_leader_pane_env_with_workspaces(
791
+ transport: &dyn Transport,
792
+ workspaces: &[&Path],
776
793
  ) -> Result<(), LifecycleError> {
777
794
  let pane_id_raw = match std::env::var("TEAM_AGENT_LEADER_PANE_ID") {
778
795
  Ok(v) if !v.is_empty() => v,
779
796
  _ => return Ok(()),
780
797
  };
781
- let probe = transport.liveness(&crate::transport::PaneId::new(&pane_id_raw));
782
- let dead = matches!(probe, Ok(crate::transport::PaneLiveness::Dead));
783
- if !dead {
798
+ let pane = crate::transport::PaneId::new(&pane_id_raw);
799
+ if !is_tmux_pane_id_format(&pane) {
800
+ write_invalid_leader_pane_env_warning(workspaces, &pane_id_raw);
784
801
  return Ok(());
785
802
  }
803
+ let failure = match leader_pane_env_state_for_validation(transport, &pane) {
804
+ LeaderPaneEnvState::Dead => Some("dead"),
805
+ LeaderPaneEnvState::Absent => Some("absent"),
806
+ LeaderPaneEnvState::Live | LeaderPaneEnvState::Unknown => None,
807
+ };
808
+ let Some(reason) = failure else {
809
+ return Ok(());
810
+ };
786
811
  Err(LifecycleError::RequirementUnmet(format!(
787
- "TEAM_AGENT_LEADER_PANE_ID points at a dead/absent pane: {pane_id_raw}\n\
812
+ "TEAM_AGENT_LEADER_PANE_ID points at a {reason} pane: {pane_id_raw}\n\
788
813
  action: unset TEAM_AGENT_LEADER_PANE_ID, or set it to a live tmux pane id\n\
789
814
  log: TEAM_AGENT_LEADER_PANE_ID={pane_id_raw}"
790
815
  )))
791
816
  }
792
817
 
818
+ fn write_invalid_leader_pane_env_warning(workspaces: &[&Path], pane_id_raw: &str) {
819
+ let message = "invalid pane id format, skipping validation";
820
+ let mut wrote = false;
821
+ let mut errors = Vec::new();
822
+ let mut seen = BTreeSet::new();
823
+ for workspace in workspaces {
824
+ let key = workspace.to_string_lossy().to_string();
825
+ if !seen.insert(key.clone()) {
826
+ continue;
827
+ }
828
+ match crate::event_log::EventLog::new(workspace).write(
829
+ "leader_pane_env.validation_warning",
830
+ serde_json::json!({
831
+ "env": "TEAM_AGENT_LEADER_PANE_ID",
832
+ "value": pane_id_raw,
833
+ "warning": message,
834
+ }),
835
+ ) {
836
+ Ok(_) => wrote = true,
837
+ Err(err) => errors.push(format!("{key}: {err}")),
838
+ }
839
+ }
840
+ if !wrote {
841
+ eprintln!("TEAM_AGENT_LEADER_PANE_ID={pane_id_raw}: {message}");
842
+ if !errors.is_empty() {
843
+ eprintln!(
844
+ "TEAM_AGENT_LEADER_PANE_ID warning event write failed: {}",
845
+ errors.join("; ")
846
+ );
847
+ }
848
+ }
849
+ }
850
+
851
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
852
+ pub(crate) enum LeaderPaneEnvState {
853
+ Live,
854
+ Dead,
855
+ Absent,
856
+ Unknown,
857
+ }
858
+
859
+ fn leader_pane_env_state_for_validation(
860
+ transport: &dyn Transport,
861
+ pane: &crate::transport::PaneId,
862
+ ) -> LeaderPaneEnvState {
863
+ if !is_tmux_pane_id_format(pane) {
864
+ return LeaderPaneEnvState::Unknown;
865
+ }
866
+ if transport.probes_real_tmux_socket_roots() {
867
+ return active_leader_pane_state_across_tmux_sockets(pane);
868
+ }
869
+ active_leader_pane_state(transport, pane)
870
+ }
871
+
872
+ fn is_tmux_pane_id_format(pane: &crate::transport::PaneId) -> bool {
873
+ let pane = pane.as_str();
874
+ pane.len() > 1 && pane.starts_with('%') && pane[1..].chars().all(|ch| ch.is_ascii_digit())
875
+ }
876
+
877
+ fn active_leader_pane_state_across_tmux_sockets(
878
+ pane: &crate::transport::PaneId,
879
+ ) -> LeaderPaneEnvState {
880
+ let endpoints = crate::tmux_backend::tmux_socket_endpoints();
881
+ let transports = endpoints
882
+ .iter()
883
+ .map(|endpoint| crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint))
884
+ .collect::<Vec<_>>();
885
+ active_leader_pane_state_across_transports(
886
+ transports.iter().map(|transport| transport as &dyn Transport),
887
+ pane,
888
+ )
889
+ }
890
+
891
+ pub(crate) fn active_leader_pane_state_across_transports<'a>(
892
+ transports: impl IntoIterator<Item = &'a dyn Transport>,
893
+ pane: &crate::transport::PaneId,
894
+ ) -> LeaderPaneEnvState {
895
+ let mut found_absent = false;
896
+ let mut found_dead = false;
897
+ for transport in transports {
898
+ match active_leader_pane_state(transport, pane) {
899
+ LeaderPaneEnvState::Live => return LeaderPaneEnvState::Live,
900
+ LeaderPaneEnvState::Dead => found_dead = true,
901
+ LeaderPaneEnvState::Absent => found_absent = true,
902
+ LeaderPaneEnvState::Unknown => {}
903
+ }
904
+ }
905
+ if found_dead {
906
+ LeaderPaneEnvState::Dead
907
+ } else if found_absent {
908
+ LeaderPaneEnvState::Absent
909
+ } else {
910
+ LeaderPaneEnvState::Unknown
911
+ }
912
+ }
913
+
914
+ fn active_leader_pane_state(
915
+ transport: &dyn Transport,
916
+ pane: &crate::transport::PaneId,
917
+ ) -> LeaderPaneEnvState {
918
+ match transport.has_pane(pane) {
919
+ Ok(Some(true)) => return LeaderPaneEnvState::Live,
920
+ Ok(Some(false)) => return LeaderPaneEnvState::Absent,
921
+ Ok(None) | Err(_) => {}
922
+ }
923
+ match transport.liveness(pane) {
924
+ Ok(crate::transport::PaneLiveness::Live) => LeaderPaneEnvState::Live,
925
+ Ok(crate::transport::PaneLiveness::Dead) => LeaderPaneEnvState::Dead,
926
+ Ok(crate::transport::PaneLiveness::Unknown) | Err(_) => LeaderPaneEnvState::Unknown,
927
+ }
928
+ }
929
+
793
930
  fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &str) {
794
931
  let Some(owner) = unbound_launched_owner(launched, launched_key) else {
795
932
  return;
@@ -1884,7 +2021,9 @@ pub fn quick_start_with_transport_in_workspace(
1884
2021
  // owner_bind / lease / display 任一消费点。被动路径(display/seed 等)各自走
1885
2022
  // 降级+event,不在这里挡。错误三行式:error(含 pane id 字面)/action(unset
1886
2023
  // 或修 env)/log(env var 名)。
1887
- validate_active_leader_pane_env(transport)?;
2024
+ let team_workspace = team_workspace(agents_dir);
2025
+ let warning_workspaces = [workspace, team_workspace.as_path()];
2026
+ validate_active_leader_pane_env_with_workspaces(transport, &warning_workspaces)?;
1888
2027
  if !agents_dir.exists() {
1889
2028
  return Err(LifecycleError::Compile(format!(
1890
2029
  "agents dir not found: {}",
@@ -1920,18 +2059,40 @@ pub fn quick_start_with_transport_in_workspace(
1920
2059
  .as_deref()
1921
2060
  .is_none_or(|team| runtime_state_has_quick_start_team(&state, team))
1922
2061
  {
2062
+ let session_name = state
2063
+ .get("session_name")
2064
+ .and_then(serde_json::Value::as_str)
2065
+ .filter(|s| !s.is_empty())
2066
+ .map(SessionName::new);
2067
+ let attach_commands = session_name
2068
+ .as_ref()
2069
+ .map(|session| {
2070
+ let windows = quick_start_attach_window_names(&state);
2071
+ crate::tmux_backend::attach_commands_for_windows(
2072
+ &workspace,
2073
+ session,
2074
+ windows.iter().map(String::as_str),
2075
+ )
2076
+ })
2077
+ .unwrap_or_default();
2078
+ let mut next_actions = vec![
2079
+ "run restart to resume the existing team or pass --fresh to replace it"
2080
+ .to_string(),
2081
+ ];
2082
+ if session_name.is_some() {
2083
+ if crate::tmux_backend::socket_probe_missing_for_workspace(&workspace) {
2084
+ next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(
2085
+ &workspace,
2086
+ ));
2087
+ }
2088
+ next_actions.extend(attach_commands.iter().cloned());
2089
+ }
1923
2090
  return Ok(QuickStartReport::ExistingRuntime {
1924
2091
  team: requested_team.clone(),
1925
- session_name: state
1926
- .get("session_name")
1927
- .and_then(serde_json::Value::as_str)
1928
- .filter(|s| !s.is_empty())
1929
- .map(SessionName::new),
2092
+ session_name,
1930
2093
  state_path: Some(state_path),
1931
- next_actions: vec![
1932
- "run restart to resume the existing team or pass --fresh to replace it"
1933
- .to_string(),
1934
- ],
2094
+ next_actions,
2095
+ attach_commands,
1935
2096
  });
1936
2097
  }
1937
2098
  }
@@ -2008,6 +2169,9 @@ pub fn quick_start_with_transport_in_workspace(
2008
2169
  let mut next_actions = vec![format!(
2009
2170
  "team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
2010
2171
  )];
2172
+ if crate::tmux_backend::socket_probe_missing_for_workspace(&workspace) {
2173
+ next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(&workspace));
2174
+ }
2011
2175
  next_actions.extend(attach_commands.iter().cloned());
2012
2176
  let display_backend = state
2013
2177
  .get("display_backend")
@@ -2024,6 +2188,29 @@ pub fn quick_start_with_transport_in_workspace(
2024
2188
  })
2025
2189
  }
2026
2190
 
2191
+ fn quick_start_attach_window_names(state: &serde_json::Value) -> Vec<String> {
2192
+ let mut windows = state
2193
+ .get("agents")
2194
+ .and_then(serde_json::Value::as_object)
2195
+ .map(|agents| {
2196
+ agents
2197
+ .iter()
2198
+ .filter_map(|(agent_id, agent)| {
2199
+ agent
2200
+ .get("window")
2201
+ .and_then(serde_json::Value::as_str)
2202
+ .filter(|window| !window.is_empty())
2203
+ .map(str::to_string)
2204
+ .or_else(|| Some(agent_id.clone()))
2205
+ })
2206
+ .collect::<Vec<_>>()
2207
+ })
2208
+ .unwrap_or_default();
2209
+ windows.sort();
2210
+ windows.dedup();
2211
+ windows
2212
+ }
2213
+
2027
2214
  /// BUG-7 helper: derive a [`QuickStartReadiness`] verdict from the just-written
2028
2215
  /// runtime state. Reads `agents[*].status`; any non-`running` agent flips the
2029
2216
  /// verdict to `Degraded { unhealthy_agents }` (sorted, deduped); otherwise
@@ -83,22 +83,28 @@ pub fn restart_with_transport_with_session_convergence_deadline(
83
83
  workspace.join("team.spec.yaml").display()
84
84
  )));
85
85
  }
86
- let run_candidate = crate::model::paths::canonical_run_workspace(workspace)
87
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
88
- if !workspace.join("team.spec.yaml").exists()
89
- && !crate::state::persist::runtime_state_path(&run_candidate).exists()
90
- {
91
- return Err(LifecycleError::TeamSelect(format!(
92
- "missing spec for restart: {}",
93
- workspace.join("team.spec.yaml").display()
94
- )));
95
- }
86
+ // RED-2(P0)修:存在性门下移到 resolve 之后,用 selected.spec_path(读序 B:runtime 优先、
87
+ // legacy 用户目录回落)判,而非 resolve 前自拼 workspace/team.spec.yaml(spec-demote 后 spec
88
+ // 不在用户目录 → 旧门误报缺)。resolve_active_team(RequireSpec) 内部已按 spec_path 校验存在性,
89
+ // 故直接交给它;失败信息(含真实 expected runtime spec 路径)即正确的 N38。
96
90
  let selected = crate::state::selector::resolve_active_team(
97
91
  workspace,
98
92
  team,
99
93
  crate::state::selector::SelectorMode::RequireSpec,
100
94
  )
101
95
  .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
96
+ // 显式存在性门(下移后):selected.spec_path 经读序 B 已定位 runtime/legacy spec。
97
+ // 缺(空目录 restart 等)→ 报真实期望路径,不误导去用户目录找。
98
+ if !selected.spec_path.as_ref().is_some_and(|p| p.exists()) {
99
+ let expected = selected
100
+ .spec_path
101
+ .clone()
102
+ .unwrap_or_else(|| crate::model::paths::runtime_spec_path(&selected.run_workspace, &selected.team_key));
103
+ return Err(LifecycleError::TeamSelect(format!(
104
+ "missing spec for restart: {} (run `team-agent quick-start <teamdir>` first, or restore the team's role docs)",
105
+ expected.display()
106
+ )));
107
+ }
102
108
  let mut state = selected.state;
103
109
  crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, None)?;
104
110
  // E5 task#3 / RC-A6a + E4(leader 裁定:每次 restart 都从角色定义重建 runtime spec,覆盖):