@team-agent/installer 0.3.6 → 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.
Files changed (45) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +52 -7
  4. package/crates/team-agent/src/cli/diagnose.rs +9 -0
  5. package/crates/team-agent/src/cli/emit.rs +175 -0
  6. package/crates/team-agent/src/cli/mod.rs +455 -63
  7. package/crates/team-agent/src/cli/status_port.rs +62 -0
  8. package/crates/team-agent/src/cli/tests/base.rs +9 -4
  9. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +83 -1
  10. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/run_delegation.rs +10 -2
  12. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +86 -21
  13. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +76 -0
  14. package/crates/team-agent/src/cli/types.rs +3 -2
  15. package/crates/team-agent/src/compiler.rs +73 -50
  16. package/crates/team-agent/src/coordinator/tick.rs +108 -20
  17. package/crates/team-agent/src/db/migration.rs +17 -1
  18. package/crates/team-agent/src/leader/owner_bind.rs +59 -20
  19. package/crates/team-agent/src/lifecycle/launch.rs +378 -56
  20. package/crates/team-agent/src/lifecycle/restart/common.rs +4 -9
  21. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +91 -12
  22. package/crates/team-agent/src/lifecycle/restart/selection.rs +6 -4
  23. package/crates/team-agent/src/lifecycle/tests/core.rs +238 -3
  24. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +257 -7
  25. package/crates/team-agent/src/lifecycle/types.rs +2 -0
  26. package/crates/team-agent/src/mcp_server/normalize.rs +29 -7
  27. package/crates/team-agent/src/mcp_server/tests/golden.rs +7 -5
  28. package/crates/team-agent/src/mcp_server/tests/normalize.rs +5 -2
  29. package/crates/team-agent/src/mcp_server/tools.rs +25 -1
  30. package/crates/team-agent/src/mcp_server/wire.rs +11 -1
  31. package/crates/team-agent/src/model/paths.rs +7 -0
  32. package/crates/team-agent/src/model/spec.rs +23 -1
  33. package/crates/team-agent/src/packaging/install.rs +42 -4
  34. package/crates/team-agent/src/packaging/tests.rs +91 -14
  35. package/crates/team-agent/src/packaging/types.rs +13 -1
  36. package/crates/team-agent/src/provider/adapter.rs +381 -15
  37. package/crates/team-agent/src/state/identity.rs +29 -0
  38. package/crates/team-agent/src/state/selector.rs +48 -14
  39. package/crates/team-agent/src/tmux_backend/tests.rs +44 -0
  40. package/crates/team-agent/src/tmux_backend.rs +104 -9
  41. package/crates/team-agent/src/transport/test_support.rs +57 -4
  42. package/crates/team-agent/src/transport.rs +13 -0
  43. package/npm/install.mjs +31 -35
  44. package/package.json +4 -4
  45. package/skills/team-agent/SKILL.md +82 -5
@@ -57,6 +57,44 @@ fn dest_dir_claude_resolves_to_dot_claude() {
57
57
  );
58
58
  }
59
59
 
60
+ #[test]
61
+ fn skill_target_copilot_maps_to_provider_copilot() {
62
+ // E9: copilot → Provider::Copilot (0.3.x 新增 provider,不归到 codex/claude).
63
+ assert_eq!(SkillTarget::Copilot.provider(), Some(Provider::Copilot));
64
+ }
65
+
66
+ #[test]
67
+ fn dest_dir_copilot_resolves_to_dot_copilot() {
68
+ // E9 实证 (.team/artifacts/copilot-probe/):copilot skill 源 = ~/.copilot/skills,
69
+ // configDir 默认 ~/.copilot → ~/.copilot/skills/team-agent.
70
+ let home = Path::new("/home/testuser");
71
+ let got = SkillTarget::Copilot.dest_dir(home);
72
+ assert_eq!(
73
+ got,
74
+ Some(SkillDestDir(PathBuf::from(
75
+ "/home/testuser/.copilot/skills/team-agent"
76
+ )))
77
+ );
78
+ }
79
+
80
+ #[test]
81
+ fn dest_dir_three_providers_distinct_locations() {
82
+ // E9: 三 provider 各自独立位置 (.codex / .claude / .copilot),互不踩.
83
+ let home = Path::new("/home/testuser");
84
+ let dirs: Vec<PathBuf> = SkillTarget::SINGLE_TARGETS
85
+ .iter()
86
+ .map(|t| t.dest_dir(home).expect("single target has dest").0)
87
+ .collect();
88
+ assert_eq!(
89
+ dirs,
90
+ vec![
91
+ PathBuf::from("/home/testuser/.codex/skills/team-agent"),
92
+ PathBuf::from("/home/testuser/.claude/skills/team-agent"),
93
+ PathBuf::from("/home/testuser/.copilot/skills/team-agent"),
94
+ ]
95
+ );
96
+ }
97
+
60
98
  #[test]
61
99
  fn dest_dir_all_is_none_not_single_dir() {
62
100
  // `All` 应 fan-out 到两者 → 非单 dir → None (skeleton:116).
@@ -520,14 +558,15 @@ fn install_skill_dry_run_single_target_reports_plan_no_side_effects() {
520
558
  }
521
559
 
522
560
  #[test]
523
- fn install_skill_dry_run_target_all_fans_out_to_two() {
524
- // commands.py:458-463 target all → 两个 outcome (codex + claude),顺序固定.
561
+ fn install_skill_dry_run_target_all_fans_out_to_all_providers() {
562
+ // E9: target all → 表驱动 fan-out 全 provider (codex + claude + copilot),顺序固定.
525
563
  let opts = skill_opts(SkillTarget::All, None, true);
526
564
  let outcomes = install_skill(&opts).expect("dry-run install-skill all");
527
- assert_eq!(outcomes.len(), 2, "all → fan-out codex+claude");
528
- // KEY ORDER:commands.py:460-461 codex first, claude second.
565
+ assert_eq!(outcomes.len(), 3, "all → fan-out codex+claude+copilot");
566
+ // KEY ORDER:SINGLE_TARGETS 顺序 codex, claude, copilot.
529
567
  assert_eq!(outcomes[0].target, SkillTarget::Codex);
530
568
  assert_eq!(outcomes[1].target, SkillTarget::Claude);
569
+ assert_eq!(outcomes[2].target, SkillTarget::Copilot);
531
570
  assert!(outcomes.iter().all(|o| o.dry_run));
532
571
  }
533
572
 
@@ -791,6 +830,43 @@ impl Drop for HomeGuard {
791
830
  }
792
831
  }
793
832
 
833
+ // E9 — install-skill --target all (real copy) must land team-agent SKILL.md in ALL THREE
834
+ // provider locations (~/.codex|.claude|.copilot/skills/team-agent) and the copied bytes must
835
+ // equal the source. This is the "三 provider 位置断言(install 后文件存在且=源)" gate.
836
+ #[test]
837
+ #[serial_test::serial(env)]
838
+ fn install_skill_all_real_copies_to_three_provider_locations() {
839
+ let _g = ENV_LOCK_PKG.lock().unwrap_or_else(|p| p.into_inner());
840
+ let base = std::env::temp_dir().join(format!("ta-e9-skill-all-{}", std::process::id()));
841
+ let home = base.join("home");
842
+ std::fs::create_dir_all(&home).unwrap();
843
+ // Build a real skill source tree with a recognizable SKILL.md.
844
+ let source = base.join("src-skill");
845
+ std::fs::create_dir_all(&source).unwrap();
846
+ let body = b"---\nname: team-agent\n---\nE9 source marker\n";
847
+ std::fs::write(source.join("SKILL.md"), body).unwrap();
848
+ let _h = HomeGuard::set(&home);
849
+
850
+ let opts = SkillInstallOptions {
851
+ target: SkillTarget::All,
852
+ dest: None,
853
+ dry_run: false,
854
+ source: source.clone(),
855
+ };
856
+ let outcomes = install_skill(&opts).expect("real install-skill all");
857
+ assert_eq!(outcomes.len(), 3, "all → codex+claude+copilot");
858
+
859
+ for sub in [".codex", ".claude", ".copilot"] {
860
+ let dest = home.join(sub).join("skills").join("team-agent").join("SKILL.md");
861
+ assert!(dest.exists(), "{sub}: SKILL.md must exist after install");
862
+ assert_eq!(
863
+ std::fs::read(&dest).unwrap(),
864
+ body,
865
+ "{sub}: installed SKILL.md bytes must equal source"
866
+ );
867
+ }
868
+ }
869
+
794
870
  // P1 — update() must perform a REAL atomic replace (rename dest→.previous), not fabricate
795
871
  // a Replaced outcome whose backup file never exists (install.mjs:60-66; bug-084).
796
872
  #[test]
@@ -819,21 +895,21 @@ fn p2_update_creates_real_atomic_replace_backup() {
819
895
  );
820
896
  }
821
897
 
822
- // P1 — uninstall() must remove BOTH ~/.codex/skills/team-agent and ~/.claude/skills/team-agent
823
- // and record them (install.mjs:115-122). Current returns removed_skill_dirs empty and leaves
824
- // the dirs on disk.
898
+ // P1 — uninstall() must remove ALL provider skill dirs (~/.codex|.claude|.copilot/skills/team-agent)
899
+ // and record them (install.mjs:115-122 + E9 copilot). Table-driven over SkillTarget::SINGLE_TARGETS.
825
900
  #[test]
826
901
  #[serial_test::serial(env)]
827
- fn p2_uninstall_removes_both_skill_dirs() {
902
+ fn p2_uninstall_removes_all_provider_skill_dirs() {
828
903
  let _g = ENV_LOCK_PKG.lock().unwrap_or_else(|p| p.into_inner());
829
904
  let base = std::env::temp_dir().join(format!("ta-p2-uninst-{}", std::process::id()));
830
905
  let home = base.join("home");
831
906
  let codex = home.join(".codex").join("skills").join("team-agent");
832
907
  let claude = home.join(".claude").join("skills").join("team-agent");
833
- std::fs::create_dir_all(&codex).unwrap();
834
- std::fs::create_dir_all(&claude).unwrap();
835
- std::fs::write(codex.join("SKILL.md"), b"x").unwrap();
836
- std::fs::write(claude.join("SKILL.md"), b"x").unwrap();
908
+ let copilot = home.join(".copilot").join("skills").join("team-agent");
909
+ for d in [&codex, &claude, &copilot] {
910
+ std::fs::create_dir_all(d).unwrap();
911
+ std::fs::write(d.join("SKILL.md"), b"x").unwrap();
912
+ }
837
913
  let _h = HomeGuard::set(&home);
838
914
 
839
915
  let opts = UninstallOptions {
@@ -844,9 +920,10 @@ fn p2_uninstall_removes_both_skill_dirs() {
844
920
  let out = uninstall(&opts).unwrap();
845
921
  assert_eq!(
846
922
  out.removed_skill_dirs.len(),
847
- 2,
848
- "uninstall must remove BOTH ~/.codex and ~/.claude skill dirs"
923
+ 3,
924
+ "uninstall must remove ~/.codex, ~/.claude AND ~/.copilot skill dirs"
849
925
  );
850
926
  assert!(!codex.exists(), "~/.codex skill dir must be removed");
851
927
  assert!(!claude.exists(), "~/.claude skill dir must be removed");
928
+ assert!(!copilot.exists(), "~/.copilot skill dir must be removed");
852
929
  }
@@ -40,24 +40,36 @@ pub enum InstallerCommand {
40
40
  pub enum SkillTarget {
41
41
  Codex,
42
42
  Claude,
43
+ /// GitHub Copilot CLI(0.3.x 新增 provider)。skill 落 `~/.copilot/skills/team-agent`
44
+ /// —— 实证:copilot bundle skill 源枚举含 `personal-copilot (~/.copilot/skills)`,
45
+ /// config dir = `~/.copilot`(可被 `$COPILOT_HOME` 覆盖),manifest 约定 `**/SKILL.md`
46
+ /// 同 claude/codex(见 `.team/artifacts/copilot-probe/`)。
47
+ Copilot,
43
48
  All,
44
49
  }
45
50
 
46
51
  impl SkillTarget {
52
+ /// `All` fan-out 的单目标全集(表驱动唯一真相源:新增 provider 只改这里,
53
+ /// install/uninstall/install-skill 三处共用,杜绝漏装/漏卸)。
54
+ pub const SINGLE_TARGETS: [SkillTarget; 3] =
55
+ [SkillTarget::Codex, SkillTarget::Claude, SkillTarget::Copilot];
56
+
47
57
  /// 单目标 → 对应 provider(`All` 无单一 provider → `None`)。与 [`Provider`] 对齐,防散字符串再生。
48
58
  pub fn provider(self) -> Option<Provider> {
49
59
  match self {
50
60
  Self::Codex => Some(Provider::Codex),
51
61
  Self::Claude => Some(Provider::ClaudeCode),
62
+ Self::Copilot => Some(Provider::Copilot),
52
63
  Self::All => None,
53
64
  }
54
65
  }
55
66
 
56
- /// `_skill_dest_dir`:`~/.codex|.claude/skills/team-agent`(`All` fan-out 到两者,非单 dir → None)。
67
+ /// `_skill_dest_dir`:`~/.codex|.claude|.copilot/skills/team-agent`(`All` fan-out 全集,非单 dir → None)。
57
68
  pub fn dest_dir(self, home: &Path) -> Option<SkillDestDir> {
58
69
  match self {
59
70
  Self::Codex => Some(SkillDestDir(home.join(".codex").join("skills").join("team-agent"))),
60
71
  Self::Claude => Some(SkillDestDir(home.join(".claude").join("skills").join("team-agent"))),
72
+ Self::Copilot => Some(SkillDestDir(home.join(".copilot").join("skills").join("team-agent"))),
61
73
  Self::All => None,
62
74
  }
63
75
  }
@@ -1025,6 +1025,43 @@ fn scan_session_candidates_once(
1025
1025
  agent_path_match,
1026
1026
  });
1027
1027
  }
1028
+ // E6 层1·C(机会性兜底):若盘上真有 expected_session_id 命名的 transcript(claude 哪天
1029
+ // 真采用 --session-id,或别的 provider 本就采用),直接唯一命中,省去时间窗扫描。
1030
+ // 命不中(交互式 claude 现实:不落 <expected>.jsonl)→ 回落 B。
1031
+ if let Some(expected) = context.expected_session_id.as_ref() {
1032
+ if let Some(hit) = out.iter().find(|candidate| {
1033
+ candidate
1034
+ .captured
1035
+ .session_id
1036
+ .as_ref()
1037
+ .is_some_and(|session| session.as_str() == expected.as_str())
1038
+ }) {
1039
+ return Ok(vec![hit.clone()]);
1040
+ }
1041
+ }
1042
+ // E6 层1·B(主路径,交互式现实):cwd 匹配但盘上有多个 sibling transcript(claude 自生成,
1043
+ // 不采用预定 UUID)→ 用 spawn 时间窗唯一选:只留 mtime >= spawned_at 的候选,打破歧义。
1044
+ // spawned_at 缺/无法解析时不收窄(保守,维持既有行为)。
1045
+ if context.expected_session_id.is_none() || out.len() > 1 {
1046
+ if let Some(spawned_at) = context.spawned_at.as_deref().and_then(parse_spawned_at) {
1047
+ let within: Vec<CapturedSessionCandidate> = out
1048
+ .iter()
1049
+ .filter(|candidate| {
1050
+ candidate
1051
+ .captured
1052
+ .rollout_path
1053
+ .as_ref()
1054
+ .and_then(|p| std::fs::metadata(p.as_path()).and_then(|m| m.modified()).ok())
1055
+ .is_some_and(|mtime| mtime >= spawned_at)
1056
+ })
1057
+ .cloned()
1058
+ .collect();
1059
+ // 只有当时间窗把候选收成唯一时才采用(收成 0 或仍多义则不强行,交给上层 ambiguous)。
1060
+ if within.len() == 1 {
1061
+ return Ok(within);
1062
+ }
1063
+ }
1064
+ }
1028
1065
  if let Some(expected) = context.expected_session_id.as_ref() {
1029
1066
  out.sort_by_key(|candidate| {
1030
1067
  candidate
@@ -1037,6 +1074,28 @@ fn scan_session_candidates_once(
1037
1074
  Ok(out)
1038
1075
  }
1039
1076
 
1077
+ /// 解析 state 里的 `spawned_at`(RFC3339)为 SystemTime,用于 spawn 时间窗候选筛选。
1078
+ /// 解析失败 → None(调用方据此不收窄时间窗,保守维持既有行为)。
1079
+ fn parse_spawned_at(raw: &str) -> Option<std::time::SystemTime> {
1080
+ chrono::DateTime::parse_from_rfc3339(raw)
1081
+ .ok()
1082
+ .map(|dt| std::time::SystemTime::from(dt.with_timezone(&chrono::Utc)))
1083
+ }
1084
+
1085
+ /// E6 层1·B:把 spawn cwd 映射成 claude transcript 子目录 `~/.claude/projects/<encoded>`。
1086
+ /// claude 用 **canonical(realpath)** cwd 且把每个 `/` 替换成 `-`(实证:cwd
1087
+ /// `/private/tmp/x` → `-private-tmp-x`;macOS `/tmp`→`/private/tmp` 必须先 canonical)。
1088
+ /// canonical 失败(目录已不在)退回原始路径,仍尽力编码。dir 不存在也返回(调用方
1089
+ /// `collect_optional_candidate_files` 对不存在目录是 no-op)。
1090
+ fn claude_projects_dir_for_cwd(home: &Path, spawn_cwd: &Path) -> Option<PathBuf> {
1091
+ let canonical = std::fs::canonicalize(spawn_cwd).unwrap_or_else(|_| spawn_cwd.to_path_buf());
1092
+ let encoded = canonical.to_string_lossy().replace('/', "-");
1093
+ if encoded.is_empty() {
1094
+ return None;
1095
+ }
1096
+ Some(home.join(".claude").join("projects").join(encoded))
1097
+ }
1098
+
1040
1099
  /// §C4 cr verdict — copilot session 真相源 sqlite 点查。
1041
1100
  ///
1042
1101
  /// 路径:`<HOME>/.copilot/session-store.db`,sessions 表(id/cwd/created_at/updated_at)
@@ -1059,30 +1118,51 @@ fn scan_copilot_session_store(context: &CaptureSessionContext) -> Vec<CapturedSe
1059
1118
  ) else {
1060
1119
  return Vec::new();
1061
1120
  };
1062
- let cwd = context.spawn_cwd.to_string_lossy().to_string();
1063
- let mut stmt = match conn.prepare(
1064
- "select id from sessions where cwd = ?1 order by updated_at desc, id desc limit 1",
1065
- ) {
1066
- Ok(stmt) => stmt,
1067
- Err(_) => return Vec::new(),
1068
- };
1069
- let row: Option<String> = stmt
1070
- .query_row([cwd.as_str()], |row| row.get::<_, String>(0))
1071
- .ok();
1072
- 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。
1073
1136
  return Vec::new();
1074
- };
1075
- 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 {
1076
1156
  captured: CapturedSession {
1077
1157
  session_id: Some(SessionId::new(session_id)),
1078
- rollout_path: Some(RolloutPath::new(db_path.clone())),
1158
+ rollout_path: Some(RolloutPath::new(db_path.to_path_buf())),
1079
1159
  captured_via: CaptureVia::FsWatch,
1080
1160
  attribution_confidence: Confidence::High,
1081
1161
  spawn_cwd: context.spawn_cwd.clone(),
1082
1162
  },
1083
1163
  positive_agent_id_match: false,
1084
1164
  agent_path_match: false,
1085
- }]
1165
+ }
1086
1166
  }
1087
1167
 
1088
1168
  fn command_on_path(name: &str) -> bool {
@@ -1113,6 +1193,12 @@ fn candidate_session_files(
1113
1193
  }
1114
1194
  Provider::Claude | Provider::ClaudeCode => {
1115
1195
  collect_optional_candidate_files(&home.join(".claude").join("sessions"), &context.agent_id, &mut out)?;
1196
+ // E6 层1·B:优先锚到 ~/.claude/projects/<canonical spawn_cwd 编码> 子目录
1197
+ // (claude 把 cwd 的 '/' 编码成 '-';交互式 worker 的真实 transcript 落在此),
1198
+ // 而非全 projects 树盲扫(交互式 claude 自生成 UUID,锚 cwd 子目录 + 时间窗才能唯一选)。
1199
+ if let Some(dir) = claude_projects_dir_for_cwd(&home, &context.spawn_cwd) {
1200
+ collect_optional_candidate_files(&dir, &context.agent_id, &mut out)?;
1201
+ }
1116
1202
  collect_optional_candidate_files(&home.join(".claude").join("projects"), &context.agent_id, &mut out)?;
1117
1203
  }
1118
1204
  // §C4 cr verdict + 设计 §C: copilot session 真相源是 ~/.copilot/session-store.db
@@ -1765,3 +1851,283 @@ fn next_session_token() -> String {
1765
1851
  bytes[15],
1766
1852
  )
1767
1853
  }
1854
+
1855
+ #[cfg(test)]
1856
+ mod e6_session_attribution_tests {
1857
+ #![allow(clippy::unwrap_used)]
1858
+ use super::*;
1859
+ use std::sync::atomic::{AtomicU64, Ordering};
1860
+
1861
+ fn tmp_root(tag: &str) -> PathBuf {
1862
+ static CTR: AtomicU64 = AtomicU64::new(0);
1863
+ let dir = std::env::temp_dir().join(format!(
1864
+ "ta-e6-attr-{}-{}-{}",
1865
+ tag,
1866
+ std::process::id(),
1867
+ CTR.fetch_add(1, Ordering::Relaxed)
1868
+ ));
1869
+ std::fs::create_dir_all(&dir).unwrap();
1870
+ dir
1871
+ }
1872
+
1873
+ fn write_transcript(dir: &Path, uuid: &str, cwd: &Path) -> PathBuf {
1874
+ std::fs::create_dir_all(dir).unwrap();
1875
+ let path = dir.join(format!("{uuid}.jsonl"));
1876
+ let line = serde_json::json!({
1877
+ "sessionId": uuid,
1878
+ "cwd": cwd.to_string_lossy(),
1879
+ });
1880
+ std::fs::write(&path, format!("{line}\n")).unwrap();
1881
+ path
1882
+ }
1883
+
1884
+ #[test]
1885
+ fn claude_projects_dir_for_cwd_encodes_slashes_to_dashes() {
1886
+ let home = Path::new("/home/u");
1887
+ // 用一个真实存在的 cwd 让 canonicalize 成功(否则退回原始);用 tmp。
1888
+ let cwd = tmp_root("encode");
1889
+ let got = claude_projects_dir_for_cwd(home, &cwd).unwrap();
1890
+ let canon = std::fs::canonicalize(&cwd).unwrap();
1891
+ let expected_leaf = canon.to_string_lossy().replace('/', "-");
1892
+ assert_eq!(got, home.join(".claude").join("projects").join(expected_leaf));
1893
+ let _ = std::fs::remove_dir_all(&cwd);
1894
+ }
1895
+
1896
+ #[test]
1897
+ fn parse_spawned_at_rfc3339_roundtrips_and_rejects_junk() {
1898
+ assert!(parse_spawned_at("2026-06-10T21:40:00+00:00").is_some());
1899
+ assert!(parse_spawned_at("not-a-date").is_none());
1900
+ assert!(parse_spawned_at("").is_none());
1901
+ }
1902
+
1903
+ #[test]
1904
+ fn scan_expected_session_id_hit_returns_only_that_candidate() {
1905
+ // C 兜底:盘上恰有 <expected>.jsonl(假设 claude 哪天真采用)→ 唯一命中,忽略 sibling。
1906
+ let base = tmp_root("c-hit");
1907
+ let cwd = base.join("ws");
1908
+ std::fs::create_dir_all(&cwd).unwrap();
1909
+ let proj = base.join("projects");
1910
+ write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
1911
+ write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
1912
+ let ctx = CaptureSessionContext {
1913
+ agent_id: "w1".to_string(),
1914
+ spawn_cwd: cwd.clone(),
1915
+ pane_id: None,
1916
+ pane_pid: None,
1917
+ spawned_at: None,
1918
+ expected_session_id: Some(SessionId::new("22222222-2222-4222-8222-222222222222")),
1919
+ provider_projects_root: Some(proj.clone()),
1920
+ };
1921
+ let out = scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
1922
+ assert_eq!(out.len(), 1, "expected-id hit must collapse to the single match");
1923
+ assert_eq!(
1924
+ out[0].captured.session_id.as_ref().unwrap().as_str(),
1925
+ "22222222-2222-4222-8222-222222222222"
1926
+ );
1927
+ let _ = std::fs::remove_dir_all(&base);
1928
+ }
1929
+
1930
+ #[test]
1931
+ fn scan_spawn_time_window_disambiguates_two_siblings() {
1932
+ // B 主路径:claude 不采用预定 UUID,盘上两个自生成 sibling 都匹配 cwd。
1933
+ // 只有一个在 spawn 时间窗内(mtime >= spawned_at)→ 时间窗唯一选出它。
1934
+ let base = tmp_root("b-window");
1935
+ let cwd = base.join("ws");
1936
+ std::fs::create_dir_all(&cwd).unwrap();
1937
+ let proj = base.join("projects");
1938
+ let old = write_transcript(&proj, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &cwd);
1939
+ let new = write_transcript(&proj, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", &cwd);
1940
+ // 把 old 的 mtime 设到很久以前,new 保持现在;spawned_at = 两者之间。
1941
+ let long_ago = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
1942
+ filetime_set(&old, long_ago);
1943
+ // spawned_at 取一个介于 old 与 new 之间、肯定早于 new 真实 mtime 的时刻(2020 年)。
1944
+ let ctx = CaptureSessionContext {
1945
+ agent_id: "w1".to_string(),
1946
+ spawn_cwd: cwd.clone(),
1947
+ pane_id: None,
1948
+ pane_pid: None,
1949
+ spawned_at: Some("2020-01-01T00:00:00+00:00".to_string()),
1950
+ expected_session_id: None,
1951
+ provider_projects_root: Some(proj.clone()),
1952
+ };
1953
+ let out = scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
1954
+ assert_eq!(out.len(), 1, "time window must collapse two siblings to one");
1955
+ assert_eq!(
1956
+ out[0].captured.session_id.as_ref().unwrap().as_str(),
1957
+ "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
1958
+ "the in-window (recent) transcript must win"
1959
+ );
1960
+ let _ = new;
1961
+ let _ = std::fs::remove_dir_all(&base);
1962
+ }
1963
+
1964
+ #[test]
1965
+ fn scan_no_spawned_at_keeps_both_siblings_ambiguous() {
1966
+ // 保守:spawned_at 缺 → 不收窄时间窗,两 sibling 仍并存(交上层 ambiguous 处理)。
1967
+ let base = tmp_root("b-noamb");
1968
+ let cwd = base.join("ws");
1969
+ std::fs::create_dir_all(&cwd).unwrap();
1970
+ let proj = base.join("projects");
1971
+ write_transcript(&proj, "cccccccc-cccc-4ccc-8ccc-cccccccccccc", &cwd);
1972
+ write_transcript(&proj, "dddddddd-dddd-4ddd-8ddd-dddddddddddd", &cwd);
1973
+ let ctx = CaptureSessionContext {
1974
+ agent_id: "w1".to_string(),
1975
+ spawn_cwd: cwd.clone(),
1976
+ pane_id: None,
1977
+ pane_pid: None,
1978
+ spawned_at: None,
1979
+ expected_session_id: None,
1980
+ provider_projects_root: Some(proj.clone()),
1981
+ };
1982
+ let out = scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
1983
+ assert!(out.len() >= 2, "no spawned_at → no time-window narrowing");
1984
+ let _ = std::fs::remove_dir_all(&base);
1985
+ }
1986
+
1987
+ fn filetime_set(path: &Path, when: std::time::SystemTime) {
1988
+ // 用 utimensat 经 std:无直接 set_mtime,借 filetime-free 方式:写后用 File::set_modified。
1989
+ let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
1990
+ f.set_modified(when).unwrap();
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
+ }
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!({});