@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +52 -7
- package/crates/team-agent/src/cli/diagnose.rs +9 -0
- package/crates/team-agent/src/cli/emit.rs +175 -0
- package/crates/team-agent/src/cli/mod.rs +455 -63
- package/crates/team-agent/src/cli/status_port.rs +62 -0
- package/crates/team-agent/src/cli/tests/base.rs +9 -4
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +83 -1
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +10 -2
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +86 -21
- package/crates/team-agent/src/cli/tests/verb_install_skill.rs +76 -0
- package/crates/team-agent/src/cli/types.rs +3 -2
- package/crates/team-agent/src/compiler.rs +73 -50
- package/crates/team-agent/src/coordinator/tick.rs +108 -20
- package/crates/team-agent/src/db/migration.rs +17 -1
- package/crates/team-agent/src/leader/owner_bind.rs +59 -20
- package/crates/team-agent/src/lifecycle/launch.rs +378 -56
- package/crates/team-agent/src/lifecycle/restart/common.rs +4 -9
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +91 -12
- package/crates/team-agent/src/lifecycle/restart/selection.rs +6 -4
- package/crates/team-agent/src/lifecycle/tests/core.rs +238 -3
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +257 -7
- package/crates/team-agent/src/lifecycle/types.rs +2 -0
- package/crates/team-agent/src/mcp_server/normalize.rs +29 -7
- package/crates/team-agent/src/mcp_server/tests/golden.rs +7 -5
- package/crates/team-agent/src/mcp_server/tests/normalize.rs +5 -2
- package/crates/team-agent/src/mcp_server/tools.rs +25 -1
- package/crates/team-agent/src/mcp_server/wire.rs +11 -1
- package/crates/team-agent/src/model/paths.rs +7 -0
- package/crates/team-agent/src/model/spec.rs +23 -1
- package/crates/team-agent/src/packaging/install.rs +42 -4
- package/crates/team-agent/src/packaging/tests.rs +91 -14
- package/crates/team-agent/src/packaging/types.rs +13 -1
- package/crates/team-agent/src/provider/adapter.rs +381 -15
- package/crates/team-agent/src/state/identity.rs +29 -0
- package/crates/team-agent/src/state/selector.rs +48 -14
- package/crates/team-agent/src/tmux_backend/tests.rs +44 -0
- package/crates/team-agent/src/tmux_backend.rs +104 -9
- package/crates/team-agent/src/transport/test_support.rs +57 -4
- package/crates/team-agent/src/transport.rs +13 -0
- package/npm/install.mjs +31 -35
- package/package.json +4 -4
- 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
|
|
524
|
-
//
|
|
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(),
|
|
528
|
-
// KEY ORDER:
|
|
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
|
|
823
|
-
// and record them (install.mjs:115-122
|
|
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
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
848
|
-
"uninstall must remove
|
|
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`
|
|
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
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
)
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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
|
-
|
|
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.
|
|
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!({});
|