@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,4 +1,5 @@
1
1
  use super::*;
2
+ use crate::transport::PaneLiveness;
2
3
 
3
4
  // ───────────────────────────────────────────────────────────────────────
4
5
  // classify_first_send_at — _classify_first_send_at (orchestration.py:404)
@@ -794,6 +795,189 @@ fn detect_dangerous_approval_clean_process_is_disabled() {
794
795
  assert!(!got.inherited);
795
796
  }
796
797
 
798
+ #[test]
799
+ #[serial_test::serial(env)]
800
+ fn leader_pane_env_absent_pane_fails_fast() {
801
+ let _leader_pane = EnvVarGuard::set("TEAM_AGENT_LEADER_PANE_ID", "%9999");
802
+ let transport =
803
+ crate::transport::test_support::OfflineTransport::new().with_pane_presence("%9999", false);
804
+ let err = validate_active_leader_pane_env(&transport).expect_err("absent pane must fail");
805
+ let msg = err.to_string();
806
+ assert!(msg.contains("TEAM_AGENT_LEADER_PANE_ID"), "got: {msg}");
807
+ assert!(msg.contains("absent"), "got: {msg}");
808
+ assert!(msg.contains("%9999"), "got: {msg}");
809
+ }
810
+
811
+ #[test]
812
+ #[serial_test::serial(env)]
813
+ fn leader_pane_env_dead_pane_fails_fast() {
814
+ let _leader_pane = EnvVarGuard::set("TEAM_AGENT_LEADER_PANE_ID", "%404");
815
+ let transport = crate::transport::test_support::OfflineTransport::new()
816
+ .with_liveness("%404", PaneLiveness::Dead);
817
+ let err = validate_active_leader_pane_env(&transport).expect_err("dead pane must fail");
818
+ let msg = err.to_string();
819
+ assert!(msg.contains("TEAM_AGENT_LEADER_PANE_ID"), "got: {msg}");
820
+ assert!(msg.contains("dead"), "got: {msg}");
821
+ assert!(msg.contains("%404"), "got: {msg}");
822
+ }
823
+
824
+ #[test]
825
+ #[serial_test::serial(env)]
826
+ fn leader_pane_env_unset_passes_without_probe() {
827
+ let _leader_pane = EnvVarGuard::remove("TEAM_AGENT_LEADER_PANE_ID");
828
+ let transport = crate::transport::test_support::OfflineTransport::new();
829
+ validate_active_leader_pane_env(&transport).expect("unset env must pass");
830
+ assert!(
831
+ transport.calls().is_empty(),
832
+ "unset env must not probe transport: {:?}",
833
+ transport.calls()
834
+ );
835
+ }
836
+
837
+ #[test]
838
+ #[serial_test::serial(env)]
839
+ fn leader_pane_env_unknown_when_unprovable_does_not_fail_fast() {
840
+ let _leader_pane = EnvVarGuard::set("TEAM_AGENT_LEADER_PANE_ID", "%8888");
841
+ let transport = crate::transport::test_support::OfflineTransport::new()
842
+ .with_default_liveness(PaneLiveness::Unknown);
843
+ validate_active_leader_pane_env(&transport).expect("unknown/unprovable pane must not fail");
844
+ }
845
+
846
+ #[test]
847
+ #[serial_test::serial(env)]
848
+ fn leader_pane_env_non_numeric_pane_id_is_unknown_not_absent() {
849
+ let _leader_pane = EnvVarGuard::set("TEAM_AGENT_LEADER_PANE_ID", "abc-notapane");
850
+ let transport = crate::transport::test_support::OfflineTransport::new()
851
+ .with_pane_presence("abc-notapane", false)
852
+ .with_liveness("abc-notapane", PaneLiveness::Dead);
853
+ validate_active_leader_pane_env(&transport)
854
+ .expect("non `%<digits>` env values are unprovable, not absent");
855
+ assert!(
856
+ transport.calls().is_empty(),
857
+ "invalid pane id format must not hit tmux probe: {:?}",
858
+ transport.calls()
859
+ );
860
+ }
861
+
862
+ #[test]
863
+ #[serial_test::serial(env)]
864
+ fn leader_pane_env_invalid_format_writes_warning_event() {
865
+ let ws = temp_ws();
866
+ let _leader_pane = EnvVarGuard::set("TEAM_AGENT_LEADER_PANE_ID", "abc-notapane");
867
+ let transport = crate::transport::test_support::OfflineTransport::new()
868
+ .with_pane_presence("abc-notapane", false);
869
+
870
+ validate_active_leader_pane_env_with_workspace(&transport, Some(&ws))
871
+ .expect("invalid format is warning-only");
872
+
873
+ let events = crate::event_log::EventLog::new(&ws).tail(0).expect("events");
874
+ let event = events
875
+ .iter()
876
+ .find(|event| {
877
+ event.get("event").and_then(serde_json::Value::as_str)
878
+ == Some("leader_pane_env.validation_warning")
879
+ })
880
+ .expect("warning event");
881
+ assert_eq!(
882
+ event.get("value").and_then(serde_json::Value::as_str),
883
+ Some("abc-notapane")
884
+ );
885
+ assert_eq!(
886
+ event.get("warning").and_then(serde_json::Value::as_str),
887
+ Some("invalid pane id format, skipping validation")
888
+ );
889
+ }
890
+
891
+ #[test]
892
+ #[serial_test::serial(env)]
893
+ fn leader_pane_env_invalid_format_writes_warning_to_all_candidate_workspaces() {
894
+ let cli_ws = temp_ws();
895
+ let team_ws = temp_ws();
896
+ let _leader_pane = EnvVarGuard::set("TEAM_AGENT_LEADER_PANE_ID", "abc-notapane");
897
+ let transport = crate::transport::test_support::OfflineTransport::new();
898
+
899
+ validate_active_leader_pane_env_with_workspaces(&transport, &[&cli_ws, &team_ws])
900
+ .expect("invalid format is warning-only");
901
+
902
+ for ws in [&cli_ws, &team_ws] {
903
+ let events = crate::event_log::EventLog::new(ws).tail(0).expect("events");
904
+ assert!(
905
+ events.iter().any(|event| {
906
+ event.get("event").and_then(serde_json::Value::as_str)
907
+ == Some("leader_pane_env.validation_warning")
908
+ && event.get("value").and_then(serde_json::Value::as_str)
909
+ == Some("abc-notapane")
910
+ }),
911
+ "warning event missing in {}: {events:?}",
912
+ ws.display()
913
+ );
914
+ }
915
+ }
916
+
917
+ #[test]
918
+ fn leader_pane_env_cross_socket_live_wins() {
919
+ let pane = crate::transport::PaneId::new("%7");
920
+ let absent =
921
+ crate::transport::test_support::OfflineTransport::new().with_pane_presence("%7", false);
922
+ let live =
923
+ crate::transport::test_support::OfflineTransport::new().with_pane_presence("%7", true);
924
+
925
+ let state = active_leader_pane_state_across_transports(
926
+ [&absent as &dyn crate::transport::Transport, &live],
927
+ &pane,
928
+ );
929
+
930
+ assert_eq!(state, LeaderPaneEnvState::Live);
931
+ }
932
+
933
+ #[test]
934
+ fn leader_pane_env_cross_socket_dead_rejects_without_live() {
935
+ let pane = crate::transport::PaneId::new("%404");
936
+ let absent = crate::transport::test_support::OfflineTransport::new()
937
+ .with_pane_presence("%404", false);
938
+ let dead = crate::transport::test_support::OfflineTransport::new()
939
+ .with_liveness("%404", PaneLiveness::Dead);
940
+
941
+ let state = active_leader_pane_state_across_transports(
942
+ [&absent as &dyn crate::transport::Transport, &dead],
943
+ &pane,
944
+ );
945
+
946
+ assert_eq!(state, LeaderPaneEnvState::Dead);
947
+ }
948
+
949
+ #[test]
950
+ fn leader_pane_env_cross_socket_absent_when_reachable_servers_confirm_missing() {
951
+ let pane = crate::transport::PaneId::new("%9999");
952
+ let absent = crate::transport::test_support::OfflineTransport::new()
953
+ .with_pane_presence("%9999", false);
954
+ let unknown = crate::transport::test_support::OfflineTransport::new()
955
+ .with_default_liveness(PaneLiveness::Unknown);
956
+
957
+ let state = active_leader_pane_state_across_transports(
958
+ [&absent as &dyn crate::transport::Transport, &unknown],
959
+ &pane,
960
+ );
961
+
962
+ assert_eq!(state, LeaderPaneEnvState::Absent);
963
+ }
964
+
965
+ #[test]
966
+ fn leader_pane_env_cross_socket_all_probe_errors_stays_unknown() {
967
+ let pane = crate::transport::PaneId::new("%maybe");
968
+ let unknown_a = crate::transport::test_support::OfflineTransport::new()
969
+ .with_default_liveness(PaneLiveness::Unknown);
970
+ let unknown_b = crate::transport::test_support::OfflineTransport::new()
971
+ .with_default_liveness(PaneLiveness::Unknown);
972
+
973
+ let state = active_leader_pane_state_across_transports(
974
+ [&unknown_a as &dyn crate::transport::Transport, &unknown_b],
975
+ &pane,
976
+ );
977
+
978
+ assert_eq!(state, LeaderPaneEnvState::Unknown);
979
+ }
980
+
797
981
  struct EnvVarGuard {
798
982
  key: &'static str,
799
983
  previous: Option<String>,
@@ -807,6 +991,14 @@ impl EnvVarGuard {
807
991
  }
808
992
  Self { key, previous }
809
993
  }
994
+
995
+ fn remove(key: &'static str) -> Self {
996
+ let previous = std::env::var(key).ok();
997
+ unsafe {
998
+ std::env::remove_var(key);
999
+ }
1000
+ Self { key, previous }
1001
+ }
810
1002
  }
811
1003
 
812
1004
  impl Drop for EnvVarGuard {
@@ -545,6 +545,42 @@ fn e5_restart_rebuilds_runtime_spec_from_role_docs() {
545
545
  );
546
546
  }
547
547
 
548
+ // RED-2 (P0) — restart existence gate must use selected.spec_path (read-order B: runtime spec),
549
+ // NOT the user-dir team.spec.yaml. spec-demote put spec under .team/runtime/<key>/, so the old
550
+ // pre-resolve gate (`!workspace.join("team.spec.yaml").exists()`) falsely reported "missing spec".
551
+ #[test]
552
+ fn red2_restart_finds_runtime_spec_not_missing_when_user_dir_has_no_spec() {
553
+ let ws = restart_ws_two_resumable_workers(); // has TEAM.md + agents + state
554
+ // Simulate spec-demote: remove any user-dir spec the fixture wrote; restart must still find
555
+ // the runtime spec (rebuilt from role docs / read-order B), NOT report "missing spec".
556
+ let _ = std::fs::remove_file(ws.join("team.spec.yaml"));
557
+ assert!(!ws.join("team.spec.yaml").exists(), "user-dir spec removed (spec-demote condition)");
558
+ let transport = OfflineTransport::new();
559
+ let result = restart_with_transport(&ws, false, None, &transport);
560
+ // The gate must NOT short-circuit with "missing spec for restart" — restart must proceed
561
+ // (any later resume/rebuild outcome is fine; we only assert the spec-missing gate is gone).
562
+ let text = format!("{result:?}");
563
+ assert!(
564
+ !text.contains("missing spec for restart"),
565
+ "RED-2: restart must locate the runtime spec via read-order B, not report missing; got {text}"
566
+ );
567
+ }
568
+
569
+ // RED-2 negative — a truly empty workspace (no role docs, no spec, no state) must STILL report
570
+ // missing spec (gate moved, not removed).
571
+ #[test]
572
+ fn red2_restart_empty_workspace_still_reports_missing() {
573
+ let ws = temp_ws(); // empty, no TEAM.md/agents/spec/state
574
+ let transport = OfflineTransport::new();
575
+ let result = restart_with_transport(&ws, false, None, &transport);
576
+ let text = format!("{result:?}");
577
+ assert!(
578
+ text.contains("missing spec") || text.to_lowercase().contains("not found") || text.contains("no_local_team_context") || text.to_lowercase().contains("invalid workspace"),
579
+ "RED-2 negative: empty workspace restart must still error (missing/not-found); got {text}"
580
+ );
581
+ let _ = std::fs::remove_dir_all(&ws);
582
+ }
583
+
548
584
  // E5 task#3 / RC-A6b — restart with role definitions MISSING explicitly refuses (lists what's
549
585
  // missing) and leaves the previous runtime spec in place (no silent path, no data destruction).
550
586
  #[test]
@@ -499,12 +499,14 @@ pub enum QuickStartReport {
499
499
  session_name: Option<SessionName>,
500
500
  state_path: Option<PathBuf>,
501
501
  next_actions: Vec<String>,
502
+ attach_commands: Vec<String>,
502
503
  },
503
504
  /// preflight 阻塞(`quick_start.py:59`)。
504
505
  PreflightBlocked {
505
506
  summary: String,
506
507
  blockers: Vec<String>,
507
508
  next_actions: Vec<String>,
509
+ attach_commands: Vec<String>,
508
510
  },
509
511
  }
510
512
 
@@ -1118,30 +1118,51 @@ fn scan_copilot_session_store(context: &CaptureSessionContext) -> Vec<CapturedSe
1118
1118
  ) else {
1119
1119
  return Vec::new();
1120
1120
  };
1121
- let cwd = context.spawn_cwd.to_string_lossy().to_string();
1122
- let mut stmt = match conn.prepare(
1123
- "select id from sessions where cwd = ?1 order by updated_at desc, id desc limit 1",
1124
- ) {
1125
- Ok(stmt) => stmt,
1126
- Err(_) => return Vec::new(),
1127
- };
1128
- let row: Option<String> = stmt
1129
- .query_row([cwd.as_str()], |row| row.get::<_, String>(0))
1130
- .ok();
1131
- let Some(session_id) = row else {
1121
+ // E11 层1(本机实锤):copilot honor `--session-id`(sessions.id == 注入的 expected_session_id),
1122
+ // worker 权威 id 可靠在 db。**expected-id 优先点查**:命中即返(High,直接根治 leader/worker
1123
+ // cwd 共享 db latest-wins 误抓 leader bug)。expected 查无 **不 promote**(E6 铁律:
1124
+ // 不硬写不在盘的假 session),回落 cwd-latest 让收敛重试。
1125
+ if let Some(expected) = context.expected_session_id.as_ref() {
1126
+ let hit: Option<String> = conn
1127
+ .prepare("select id from sessions where id = ?1 limit 1")
1128
+ .ok()
1129
+ .and_then(|mut stmt| {
1130
+ stmt.query_row([expected.as_str()], |row| row.get::<_, String>(0)).ok()
1131
+ });
1132
+ if let Some(session_id) = hit {
1133
+ return vec![copilot_candidate(session_id, &db_path, context)];
1134
+ }
1135
+ // expected 设了但 db 无该 id → 返空(收敛重试),绝不回落抓别人(尤其 leader)的 latest。
1132
1136
  return Vec::new();
1133
- };
1134
- vec![CapturedSessionCandidate {
1137
+ }
1138
+ // 无 expected → **返空**(保守,不 cwd-latest 猜)。
1139
+ // E11 层1 兜底洞(architect 核实):leader+worker 同 cwd 共享 db 时,cwd-latest 可能抓 leader
1140
+ // 的 session;而 allocator 的 claimed 去重只扫 state.agents,**leader 在 state.leader/team_owner
1141
+ // 不在 agents → 兜不住**;且 leader 的 copilot session_id 运行期不入 state(team_owner 只存
1142
+ // leader_session_uuid,非 copilot db id),故无从显式排除。所幸 copilot build_command_plan **总**
1143
+ // 注入 --session-id(expected_session_id 恒 Some)→ 真实 copilot worker 永走上面点查路径,
1144
+ // 此 expected=None 分支对真实 worker 不可达。故直接返空最干净:不猜、绝不把 leader session 分给
1145
+ // worker。db 留 _。
1146
+ let _ = (&db_path, &conn);
1147
+ Vec::new()
1148
+ }
1149
+
1150
+ fn copilot_candidate(
1151
+ session_id: String,
1152
+ db_path: &Path,
1153
+ context: &CaptureSessionContext,
1154
+ ) -> CapturedSessionCandidate {
1155
+ CapturedSessionCandidate {
1135
1156
  captured: CapturedSession {
1136
1157
  session_id: Some(SessionId::new(session_id)),
1137
- rollout_path: Some(RolloutPath::new(db_path.clone())),
1158
+ rollout_path: Some(RolloutPath::new(db_path.to_path_buf())),
1138
1159
  captured_via: CaptureVia::FsWatch,
1139
1160
  attribution_confidence: Confidence::High,
1140
1161
  spawn_cwd: context.spawn_cwd.clone(),
1141
1162
  },
1142
1163
  positive_agent_id_match: false,
1143
1164
  agent_path_match: false,
1144
- }]
1165
+ }
1145
1166
  }
1146
1167
 
1147
1168
  fn command_on_path(name: &str) -> bool {
@@ -1968,4 +1989,145 @@ mod e6_session_attribution_tests {
1968
1989
  let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
1969
1990
  f.set_modified(when).unwrap();
1970
1991
  }
1992
+
1993
+ // ── E11 层1:copilot session 归因(expected-id 优先,不抓 leader latest)──
1994
+ struct HomeGuard {
1995
+ prev: Option<std::ffi::OsString>,
1996
+ }
1997
+ impl HomeGuard {
1998
+ fn set(home: &Path) -> Self {
1999
+ let prev = std::env::var_os("HOME");
2000
+ std::env::set_var("HOME", home);
2001
+ Self { prev }
2002
+ }
2003
+ }
2004
+ impl Drop for HomeGuard {
2005
+ fn drop(&mut self) {
2006
+ match &self.prev {
2007
+ Some(v) => std::env::set_var("HOME", v),
2008
+ None => std::env::remove_var("HOME"),
2009
+ }
2010
+ }
2011
+ }
2012
+
2013
+ /// 造一个 copilot session-store.db,写 (id, cwd, updated_at) 行。
2014
+ fn seed_copilot_db(home: &Path, rows: &[(&str, &str, i64)]) {
2015
+ let dir = home.join(".copilot");
2016
+ std::fs::create_dir_all(&dir).unwrap();
2017
+ let conn = rusqlite::Connection::open(dir.join("session-store.db")).unwrap();
2018
+ conn.execute(
2019
+ "create table sessions (id text primary key, cwd text, updated_at integer)",
2020
+ [],
2021
+ )
2022
+ .unwrap();
2023
+ for (id, cwd, updated) in rows {
2024
+ conn.execute(
2025
+ "insert into sessions (id, cwd, updated_at) values (?1, ?2, ?3)",
2026
+ rusqlite::params![id, cwd, updated],
2027
+ )
2028
+ .unwrap();
2029
+ }
2030
+ }
2031
+
2032
+ #[test]
2033
+ #[serial_test::serial(env)]
2034
+ fn copilot_expected_id_wins_over_leader_latest_same_cwd() {
2035
+ // 真机复现的确定性 fixture:leader row updated 晚(latest),worker row id == expected。
2036
+ // capture 必返 worker 自己的 id,不返 leader 的 latest。
2037
+ let base = tmp_root("e11-copilot");
2038
+ let home = base.join("home");
2039
+ std::fs::create_dir_all(&home).unwrap();
2040
+ let cwd = base.join("ws");
2041
+ std::fs::create_dir_all(&cwd).unwrap();
2042
+ let worker_id = "1142c4c2-0000-4000-8000-000000000001";
2043
+ let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
2044
+ // leader updated_at 更大(latest-wins 会抓它);worker 更早。
2045
+ seed_copilot_db(
2046
+ &home,
2047
+ &[
2048
+ (worker_id, &cwd.to_string_lossy(), 100),
2049
+ (leader_id, &cwd.to_string_lossy(), 999),
2050
+ ],
2051
+ );
2052
+ let _h = HomeGuard::set(&home);
2053
+ let ctx = CaptureSessionContext {
2054
+ agent_id: "worker".to_string(),
2055
+ spawn_cwd: cwd.clone(),
2056
+ pane_id: None,
2057
+ pane_pid: None,
2058
+ spawned_at: None,
2059
+ expected_session_id: Some(SessionId::new(worker_id)),
2060
+ provider_projects_root: None,
2061
+ };
2062
+ let out = scan_copilot_session_store(&ctx);
2063
+ assert_eq!(out.len(), 1, "expected-id point query → single authoritative candidate");
2064
+ assert_eq!(
2065
+ out[0].captured.session_id.as_ref().unwrap().as_str(),
2066
+ worker_id,
2067
+ "must return worker's own (expected) session, NOT leader's latest"
2068
+ );
2069
+ drop(_h);
2070
+ let _ = std::fs::remove_dir_all(&base);
2071
+ }
2072
+
2073
+ #[test]
2074
+ #[serial_test::serial(env)]
2075
+ fn copilot_expected_id_absent_in_db_returns_empty_not_leader() {
2076
+ // expected 设了但 db 无该 id(会话还没落)→ 返空(收敛重试),绝不回落抓 leader latest。
2077
+ let base = tmp_root("e11-copilot-absent");
2078
+ let home = base.join("home");
2079
+ std::fs::create_dir_all(&home).unwrap();
2080
+ let cwd = base.join("ws");
2081
+ std::fs::create_dir_all(&cwd).unwrap();
2082
+ let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
2083
+ seed_copilot_db(&home, &[(leader_id, &cwd.to_string_lossy(), 999)]);
2084
+ let _h = HomeGuard::set(&home);
2085
+ let ctx = CaptureSessionContext {
2086
+ agent_id: "worker".to_string(),
2087
+ spawn_cwd: cwd.clone(),
2088
+ pane_id: None,
2089
+ pane_pid: None,
2090
+ spawned_at: None,
2091
+ expected_session_id: Some(SessionId::new("1142c4c2-0000-4000-8000-000000000001")),
2092
+ provider_projects_root: None,
2093
+ };
2094
+ let out = scan_copilot_session_store(&ctx);
2095
+ assert!(
2096
+ out.is_empty(),
2097
+ "expected id absent in db → empty (no promote, no leader latest); got {out:?}"
2098
+ );
2099
+ drop(_h);
2100
+ let _ = std::fs::remove_dir_all(&base);
2101
+ }
2102
+
2103
+ #[test]
2104
+ #[serial_test::serial(env)]
2105
+ fn copilot_no_expected_same_cwd_only_leader_row_returns_empty_not_leader() {
2106
+ // E11 层1 兜底洞(architect):无 expected + 同 cwd 仅 leader row → 必返空,绝不返 leader。
2107
+ // (真实 copilot worker 恒有 expected,此分支不可达;保守返空堵住 allocator 不排除 leader 的洞。)
2108
+ let base = tmp_root("e11-copilot-noexp");
2109
+ let home = base.join("home");
2110
+ std::fs::create_dir_all(&home).unwrap();
2111
+ let cwd = base.join("ws");
2112
+ std::fs::create_dir_all(&cwd).unwrap();
2113
+ let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
2114
+ seed_copilot_db(&home, &[(leader_id, &cwd.to_string_lossy(), 999)]);
2115
+ let _h = HomeGuard::set(&home);
2116
+ let ctx = CaptureSessionContext {
2117
+ agent_id: "worker".to_string(),
2118
+ spawn_cwd: cwd.clone(),
2119
+ pane_id: None,
2120
+ pane_pid: None,
2121
+ spawned_at: None,
2122
+ expected_session_id: None, // 无 expected → 兜底路径
2123
+ provider_projects_root: None,
2124
+ };
2125
+ let out = scan_copilot_session_store(&ctx);
2126
+ assert!(
2127
+ out.is_empty(),
2128
+ "no expected + only leader row in same cwd → must return empty, NOT leader; got {out:?}"
2129
+ );
2130
+ drop(_h);
2131
+ let _ = std::fs::remove_dir_all(&base);
2132
+ }
1971
2133
  }
@@ -422,6 +422,7 @@ fn py_repr_str(s: &str) -> String {
422
422
  mod tests {
423
423
  #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
424
424
  use super::*;
425
+ use serial_test::serial;
425
426
  use std::collections::HashMap;
426
427
  use std::sync::atomic::{AtomicU32, Ordering};
427
428
 
@@ -446,6 +447,32 @@ mod tests {
446
447
  ws
447
448
  }
448
449
 
450
+ struct EnvUnsetGuard {
451
+ previous: Vec<(&'static str, Option<String>)>,
452
+ }
453
+ impl EnvUnsetGuard {
454
+ fn unset(keys: &[&'static str]) -> Self {
455
+ let previous = keys
456
+ .iter()
457
+ .map(|key| (*key, std::env::var(key).ok()))
458
+ .collect::<Vec<_>>();
459
+ for key in keys {
460
+ std::env::remove_var(key);
461
+ }
462
+ Self { previous }
463
+ }
464
+ }
465
+ impl Drop for EnvUnsetGuard {
466
+ fn drop(&mut self) {
467
+ for (key, value) in self.previous.drain(..).rev() {
468
+ match value {
469
+ Some(value) => std::env::set_var(key, value),
470
+ None => std::env::remove_var(key),
471
+ }
472
+ }
473
+ }
474
+ }
475
+
449
476
  #[test]
450
477
  fn os_user_and_fingerprint() {
451
478
  assert_eq!(identity_os_user(&env(&[("USER", "alice")])), "alice");
@@ -560,7 +587,9 @@ mod tests {
560
587
  }
561
588
 
562
589
  #[test]
590
+ #[serial(env)]
563
591
  fn apply_first_time_binding_success_writes_state() {
592
+ let _env = EnvUnsetGuard::unset(&["TMUX", "TMUX_PANE"]);
564
593
  let ws = temp_ws();
565
594
  let now = "2026-06-02T09:17:59.994383+00:00";
566
595
  let mut state = json!({});
@@ -503,6 +503,50 @@
503
503
  );
504
504
  }
505
505
 
506
+ #[test]
507
+ fn has_pane_is_direct_existence_probe_not_liveness_guess() {
508
+ let (be, rec) = backend_with(MockResp::Out(ok("%7")), vec![]);
509
+ assert_eq!(be.has_pane(&PaneId::new("%7")).expect("has_pane"), Some(true));
510
+ let argv0 = rec.lock().unwrap()[0].clone();
511
+ assert!(
512
+ argv0.contains(&"display-message".to_string())
513
+ && argv0.iter().any(|x| x.contains("#{pane_id}"))
514
+ && argv0.contains(&"%7".to_string()),
515
+ "has_pane must use the cheap display-message #{{pane_id}} probe; got {argv0:?}"
516
+ );
517
+
518
+ let (be, _r) = backend_with(MockResp::Out(ok("")), vec![]);
519
+ assert_eq!(
520
+ be.has_pane(&PaneId::new("%9999")).expect("has_pane"),
521
+ Some(false),
522
+ "real tmux can report a missing pane as exit 0 with empty stdout"
523
+ );
524
+
525
+ let (be, _r) = backend_with(MockResp::Out(fail(1, "can't find pane: %9999")), vec![]);
526
+ assert_eq!(be.has_pane(&PaneId::new("%9999")).expect("has_pane"), Some(false));
527
+
528
+ let (be, _r) = backend_with(MockResp::Out(ok("%8")), vec![]);
529
+ assert_eq!(
530
+ be.has_pane(&PaneId::new("%7")).expect("has_pane"),
531
+ None,
532
+ "a successful but mismatched pane id is not proof that the requested pane exists"
533
+ );
534
+
535
+ let (be, _r) = backend_with(MockResp::Out(ok("not-a-pane")), vec![]);
536
+ assert_eq!(
537
+ be.has_pane(&PaneId::new("%7")).expect("has_pane"),
538
+ None,
539
+ "a successful but invalid pane id stays Unknown"
540
+ );
541
+
542
+ let (be, _r) = backend_with(MockResp::Out(fail(1, "error connecting to server: No such file or directory")), vec![]);
543
+ assert_eq!(
544
+ be.has_pane(&PaneId::new("%7")).expect("has_pane"),
545
+ None,
546
+ "server/probe errors remain Unknown, not absent"
547
+ );
548
+ }
549
+
506
550
  // ── CP-1: per-team socket — for_workspace injects `-L ta-<hash>` at the run chokepoint; new() does NOT ─
507
551
  #[test]
508
552
  fn for_workspace_backend_injects_per_team_socket_but_default_backend_does_not() {