@team-agent/installer 0.3.6 → 0.3.7
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/diagnose.rs +9 -0
- package/crates/team-agent/src/cli/emit.rs +63 -0
- package/crates/team-agent/src/cli/mod.rs +334 -35
- 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/run_delegation.rs +10 -2
- 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/lifecycle/launch.rs +182 -47
- package/crates/team-agent/src/lifecycle/restart/common.rs +4 -9
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +75 -2
- package/crates/team-agent/src/lifecycle/restart/selection.rs +6 -4
- package/crates/team-agent/src/lifecycle/tests/core.rs +46 -3
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +221 -7
- 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 +204 -0
- package/crates/team-agent/src/state/selector.rs +48 -14
- package/crates/team-agent/src/tmux_backend.rs +14 -2
- 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)
|
|
@@ -1113,6 +1172,12 @@ fn candidate_session_files(
|
|
|
1113
1172
|
}
|
|
1114
1173
|
Provider::Claude | Provider::ClaudeCode => {
|
|
1115
1174
|
collect_optional_candidate_files(&home.join(".claude").join("sessions"), &context.agent_id, &mut out)?;
|
|
1175
|
+
// E6 层1·B:优先锚到 ~/.claude/projects/<canonical spawn_cwd 编码> 子目录
|
|
1176
|
+
// (claude 把 cwd 的 '/' 编码成 '-';交互式 worker 的真实 transcript 落在此),
|
|
1177
|
+
// 而非全 projects 树盲扫(交互式 claude 自生成 UUID,锚 cwd 子目录 + 时间窗才能唯一选)。
|
|
1178
|
+
if let Some(dir) = claude_projects_dir_for_cwd(&home, &context.spawn_cwd) {
|
|
1179
|
+
collect_optional_candidate_files(&dir, &context.agent_id, &mut out)?;
|
|
1180
|
+
}
|
|
1116
1181
|
collect_optional_candidate_files(&home.join(".claude").join("projects"), &context.agent_id, &mut out)?;
|
|
1117
1182
|
}
|
|
1118
1183
|
// §C4 cr verdict + 设计 §C: copilot session 真相源是 ~/.copilot/session-store.db
|
|
@@ -1765,3 +1830,142 @@ fn next_session_token() -> String {
|
|
|
1765
1830
|
bytes[15],
|
|
1766
1831
|
)
|
|
1767
1832
|
}
|
|
1833
|
+
|
|
1834
|
+
#[cfg(test)]
|
|
1835
|
+
mod e6_session_attribution_tests {
|
|
1836
|
+
#![allow(clippy::unwrap_used)]
|
|
1837
|
+
use super::*;
|
|
1838
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
1839
|
+
|
|
1840
|
+
fn tmp_root(tag: &str) -> PathBuf {
|
|
1841
|
+
static CTR: AtomicU64 = AtomicU64::new(0);
|
|
1842
|
+
let dir = std::env::temp_dir().join(format!(
|
|
1843
|
+
"ta-e6-attr-{}-{}-{}",
|
|
1844
|
+
tag,
|
|
1845
|
+
std::process::id(),
|
|
1846
|
+
CTR.fetch_add(1, Ordering::Relaxed)
|
|
1847
|
+
));
|
|
1848
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
1849
|
+
dir
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
fn write_transcript(dir: &Path, uuid: &str, cwd: &Path) -> PathBuf {
|
|
1853
|
+
std::fs::create_dir_all(dir).unwrap();
|
|
1854
|
+
let path = dir.join(format!("{uuid}.jsonl"));
|
|
1855
|
+
let line = serde_json::json!({
|
|
1856
|
+
"sessionId": uuid,
|
|
1857
|
+
"cwd": cwd.to_string_lossy(),
|
|
1858
|
+
});
|
|
1859
|
+
std::fs::write(&path, format!("{line}\n")).unwrap();
|
|
1860
|
+
path
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
#[test]
|
|
1864
|
+
fn claude_projects_dir_for_cwd_encodes_slashes_to_dashes() {
|
|
1865
|
+
let home = Path::new("/home/u");
|
|
1866
|
+
// 用一个真实存在的 cwd 让 canonicalize 成功(否则退回原始);用 tmp。
|
|
1867
|
+
let cwd = tmp_root("encode");
|
|
1868
|
+
let got = claude_projects_dir_for_cwd(home, &cwd).unwrap();
|
|
1869
|
+
let canon = std::fs::canonicalize(&cwd).unwrap();
|
|
1870
|
+
let expected_leaf = canon.to_string_lossy().replace('/', "-");
|
|
1871
|
+
assert_eq!(got, home.join(".claude").join("projects").join(expected_leaf));
|
|
1872
|
+
let _ = std::fs::remove_dir_all(&cwd);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
#[test]
|
|
1876
|
+
fn parse_spawned_at_rfc3339_roundtrips_and_rejects_junk() {
|
|
1877
|
+
assert!(parse_spawned_at("2026-06-10T21:40:00+00:00").is_some());
|
|
1878
|
+
assert!(parse_spawned_at("not-a-date").is_none());
|
|
1879
|
+
assert!(parse_spawned_at("").is_none());
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
#[test]
|
|
1883
|
+
fn scan_expected_session_id_hit_returns_only_that_candidate() {
|
|
1884
|
+
// C 兜底:盘上恰有 <expected>.jsonl(假设 claude 哪天真采用)→ 唯一命中,忽略 sibling。
|
|
1885
|
+
let base = tmp_root("c-hit");
|
|
1886
|
+
let cwd = base.join("ws");
|
|
1887
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
1888
|
+
let proj = base.join("projects");
|
|
1889
|
+
write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
|
|
1890
|
+
write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
|
|
1891
|
+
let ctx = CaptureSessionContext {
|
|
1892
|
+
agent_id: "w1".to_string(),
|
|
1893
|
+
spawn_cwd: cwd.clone(),
|
|
1894
|
+
pane_id: None,
|
|
1895
|
+
pane_pid: None,
|
|
1896
|
+
spawned_at: None,
|
|
1897
|
+
expected_session_id: Some(SessionId::new("22222222-2222-4222-8222-222222222222")),
|
|
1898
|
+
provider_projects_root: Some(proj.clone()),
|
|
1899
|
+
};
|
|
1900
|
+
let out = scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
|
|
1901
|
+
assert_eq!(out.len(), 1, "expected-id hit must collapse to the single match");
|
|
1902
|
+
assert_eq!(
|
|
1903
|
+
out[0].captured.session_id.as_ref().unwrap().as_str(),
|
|
1904
|
+
"22222222-2222-4222-8222-222222222222"
|
|
1905
|
+
);
|
|
1906
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
#[test]
|
|
1910
|
+
fn scan_spawn_time_window_disambiguates_two_siblings() {
|
|
1911
|
+
// B 主路径:claude 不采用预定 UUID,盘上两个自生成 sibling 都匹配 cwd。
|
|
1912
|
+
// 只有一个在 spawn 时间窗内(mtime >= spawned_at)→ 时间窗唯一选出它。
|
|
1913
|
+
let base = tmp_root("b-window");
|
|
1914
|
+
let cwd = base.join("ws");
|
|
1915
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
1916
|
+
let proj = base.join("projects");
|
|
1917
|
+
let old = write_transcript(&proj, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &cwd);
|
|
1918
|
+
let new = write_transcript(&proj, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", &cwd);
|
|
1919
|
+
// 把 old 的 mtime 设到很久以前,new 保持现在;spawned_at = 两者之间。
|
|
1920
|
+
let long_ago = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
|
|
1921
|
+
filetime_set(&old, long_ago);
|
|
1922
|
+
// spawned_at 取一个介于 old 与 new 之间、肯定早于 new 真实 mtime 的时刻(2020 年)。
|
|
1923
|
+
let ctx = CaptureSessionContext {
|
|
1924
|
+
agent_id: "w1".to_string(),
|
|
1925
|
+
spawn_cwd: cwd.clone(),
|
|
1926
|
+
pane_id: None,
|
|
1927
|
+
pane_pid: None,
|
|
1928
|
+
spawned_at: Some("2020-01-01T00:00:00+00:00".to_string()),
|
|
1929
|
+
expected_session_id: None,
|
|
1930
|
+
provider_projects_root: Some(proj.clone()),
|
|
1931
|
+
};
|
|
1932
|
+
let out = scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
|
|
1933
|
+
assert_eq!(out.len(), 1, "time window must collapse two siblings to one");
|
|
1934
|
+
assert_eq!(
|
|
1935
|
+
out[0].captured.session_id.as_ref().unwrap().as_str(),
|
|
1936
|
+
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
1937
|
+
"the in-window (recent) transcript must win"
|
|
1938
|
+
);
|
|
1939
|
+
let _ = new;
|
|
1940
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
#[test]
|
|
1944
|
+
fn scan_no_spawned_at_keeps_both_siblings_ambiguous() {
|
|
1945
|
+
// 保守:spawned_at 缺 → 不收窄时间窗,两 sibling 仍并存(交上层 ambiguous 处理)。
|
|
1946
|
+
let base = tmp_root("b-noamb");
|
|
1947
|
+
let cwd = base.join("ws");
|
|
1948
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
1949
|
+
let proj = base.join("projects");
|
|
1950
|
+
write_transcript(&proj, "cccccccc-cccc-4ccc-8ccc-cccccccccccc", &cwd);
|
|
1951
|
+
write_transcript(&proj, "dddddddd-dddd-4ddd-8ddd-dddddddddddd", &cwd);
|
|
1952
|
+
let ctx = CaptureSessionContext {
|
|
1953
|
+
agent_id: "w1".to_string(),
|
|
1954
|
+
spawn_cwd: cwd.clone(),
|
|
1955
|
+
pane_id: None,
|
|
1956
|
+
pane_pid: None,
|
|
1957
|
+
spawned_at: None,
|
|
1958
|
+
expected_session_id: None,
|
|
1959
|
+
provider_projects_root: Some(proj.clone()),
|
|
1960
|
+
};
|
|
1961
|
+
let out = scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
|
|
1962
|
+
assert!(out.len() >= 2, "no spawned_at → no time-window narrowing");
|
|
1963
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
fn filetime_set(path: &Path, when: std::time::SystemTime) {
|
|
1967
|
+
// 用 utimensat 经 std:无直接 set_mtime,借 filetime-free 方式:写后用 File::set_modified。
|
|
1968
|
+
let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
|
|
1969
|
+
f.set_modified(when).unwrap();
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
@@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
|
|
|
4
4
|
|
|
5
5
|
use serde_json::Value;
|
|
6
6
|
|
|
7
|
-
use crate::model::paths::{canonical_run_workspace, team_workspace};
|
|
7
|
+
use crate::model::paths::{canonical_run_workspace, runtime_spec_path, team_workspace};
|
|
8
8
|
use crate::state::persist::{load_runtime_state, runtime_state_path};
|
|
9
9
|
use crate::state::projection::{select_runtime_state, team_state_key};
|
|
10
10
|
use crate::state::StateError;
|
|
@@ -20,7 +20,13 @@ pub struct SelectedTeam {
|
|
|
20
20
|
pub run_workspace: PathBuf,
|
|
21
21
|
pub team_key: String,
|
|
22
22
|
pub state: Value,
|
|
23
|
+
/// E5 §3 解耦:**角色定义目录**(用户目录,含 TEAM.md+agents/*.md+profiles)。
|
|
24
|
+
/// 给 compile_team / 找角色定义 / profiles。**永远是用户目录**,不是 spec 落点。
|
|
25
|
+
/// 来源 = state.team_dir;缺则回落 run_workspace(自含 team-dir 布局)。
|
|
26
|
+
pub team_dir: PathBuf,
|
|
27
|
+
/// spec yaml 所在目录(demote 后 = .team/runtime/<team_key>/)。读写 spec yaml 用。
|
|
23
28
|
pub spec_workspace: Option<PathBuf>,
|
|
29
|
+
/// spec yaml 路径(= runtime_spec_path(run_ws, team_key))。读写 spec yaml 用。
|
|
24
30
|
pub spec_path: Option<PathBuf>,
|
|
25
31
|
}
|
|
26
32
|
|
|
@@ -30,7 +36,7 @@ pub fn resolve_active_team(
|
|
|
30
36
|
mode: SelectorMode,
|
|
31
37
|
) -> Result<SelectedTeam, StateError> {
|
|
32
38
|
let explicit_spec = input.join("team.spec.yaml");
|
|
33
|
-
let (run_workspace, state
|
|
39
|
+
let (run_workspace, state) = if explicit_spec.exists() {
|
|
34
40
|
let team_run = team_workspace(input).map_err(|e| StateError::TeamSelect(e.to_string()))?;
|
|
35
41
|
let run = if runtime_state_path(input).exists() || !runtime_state_path(&team_run).exists() {
|
|
36
42
|
input.to_path_buf()
|
|
@@ -38,7 +44,7 @@ pub fn resolve_active_team(
|
|
|
38
44
|
team_run
|
|
39
45
|
};
|
|
40
46
|
let state = select_runtime_state(&run, team).or_else(|_| load_runtime_state(&run))?;
|
|
41
|
-
(run, state
|
|
47
|
+
(run, state)
|
|
42
48
|
} else {
|
|
43
49
|
let run = canonical_run_workspace(input)
|
|
44
50
|
.map_err(|e| StateError::TeamSelect(e.to_string()))?;
|
|
@@ -53,30 +59,58 @@ pub fn resolve_active_team(
|
|
|
53
59
|
)));
|
|
54
60
|
}
|
|
55
61
|
let state = select_runtime_state(&run, team).or_else(|_| load_runtime_state(&run))?;
|
|
56
|
-
|
|
57
|
-
.or_else(|| run.join("team.spec.yaml").exists().then(|| run.clone()));
|
|
58
|
-
(run, state, spec_workspace)
|
|
62
|
+
(run, state)
|
|
59
63
|
};
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
// E5 spec 迁移·读序 B(architect+leader 裁定):
|
|
66
|
+
// 1) runtime spec 优先严格:<run_ws>/.team/runtime/<team_key>/team.spec.yaml 存在即必用。
|
|
67
|
+
// 2) 缺失才**只读回落**用户目录旧 spec(过渡腿;绝不在此写/迁移——迁移+清理只属启动重建)。
|
|
68
|
+
// TODO(E5 后续版本):新 team 永不写用户目录(G1),回落腿可在 legacy 清零后移除。
|
|
69
|
+
let team_key = selected_team_key(&state, team);
|
|
70
|
+
let runtime_spec = runtime_spec_path(&run_workspace, &team_key);
|
|
71
|
+
let (spec_workspace, spec_path) = if runtime_spec.exists() {
|
|
72
|
+
(
|
|
73
|
+
runtime_spec.parent().map(Path::to_path_buf),
|
|
74
|
+
Some(runtime_spec.clone()),
|
|
75
|
+
)
|
|
76
|
+
} else {
|
|
77
|
+
// 回落(只读):优先 explicit input/team.spec.yaml,其次 state 推断的 spec_workspace。
|
|
78
|
+
let legacy_ws = if explicit_spec.exists() {
|
|
79
|
+
Some(input.to_path_buf())
|
|
80
|
+
} else {
|
|
81
|
+
spec_workspace_from_state(&state)
|
|
82
|
+
.or_else(|| run_workspace.join("team.spec.yaml").exists().then(|| run_workspace.clone()))
|
|
83
|
+
};
|
|
84
|
+
let legacy_spec = legacy_ws.as_ref().map(|ws| ws.join("team.spec.yaml"));
|
|
85
|
+
(legacy_ws, legacy_spec)
|
|
86
|
+
};
|
|
62
87
|
if matches!(mode, SelectorMode::RequireSpec) && !spec_path.as_ref().is_some_and(|path| path.exists()) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
88
|
+
// 期望路径报 canonical runtime spec(重建落点),非用户目录。
|
|
89
|
+
let expected = spec_path.as_ref().cloned().unwrap_or(runtime_spec);
|
|
90
|
+
// E5 Bug2 N38:spec=中间产物,运行期由 restart 以角色定义重建;首装走 quick-start;
|
|
91
|
+
// 加新角色用 add-agent。不再提 reconcile(已废)。
|
|
67
92
|
return Err(StateError::TeamSelect(format!(
|
|
68
|
-
"active team spec not found: input_workspace={} run_workspace={} team_key={} expected_spec_path={} hint=run quick-start
|
|
93
|
+
"active team spec not found: input_workspace={} run_workspace={} team_key={} expected_spec_path={} hint=run `team-agent restart` to rebuild it from the role docs, or `team-agent quick-start <teamdir>` for first launch (to add a role at runtime use `team-agent add-agent <id> --role-file <path>`)",
|
|
69
94
|
input.display(),
|
|
70
95
|
run_workspace.display(),
|
|
71
|
-
|
|
96
|
+
team_key,
|
|
72
97
|
expected.display()
|
|
73
98
|
)));
|
|
74
99
|
}
|
|
75
100
|
|
|
101
|
+
// E5 §3 解耦:team_dir = 角色定义目录(用户目录),恒取 state.team_dir;缺则回落 run_workspace。
|
|
102
|
+
let team_dir = state
|
|
103
|
+
.get("team_dir")
|
|
104
|
+
.and_then(Value::as_str)
|
|
105
|
+
.filter(|s| !s.is_empty())
|
|
106
|
+
.map(PathBuf::from)
|
|
107
|
+
.unwrap_or_else(|| run_workspace.clone());
|
|
108
|
+
|
|
76
109
|
Ok(SelectedTeam {
|
|
77
110
|
run_workspace,
|
|
78
|
-
team_key
|
|
111
|
+
team_key,
|
|
79
112
|
state,
|
|
113
|
+
team_dir,
|
|
80
114
|
spec_workspace,
|
|
81
115
|
spec_path,
|
|
82
116
|
})
|
|
@@ -448,9 +448,21 @@ impl TmuxBackend {
|
|
|
448
448
|
];
|
|
449
449
|
let output = self.run_spawn(&pane_argv)?;
|
|
450
450
|
let pane = output.stdout.trim();
|
|
451
|
-
|
|
451
|
+
// T3-5 (harvest §1): never fabricate a `%0` pane id on an empty reply — a fake
|
|
452
|
+
// pane id mis-addresses every later inject/capture/kill. Surface the miss.
|
|
453
|
+
if pane.is_empty() {
|
|
454
|
+
return Err(TransportError::Subprocess {
|
|
455
|
+
argv: pane_argv,
|
|
456
|
+
code: output.code,
|
|
457
|
+
stderr: format!(
|
|
458
|
+
"tmux display-message returned no pane id for {}:{}",
|
|
459
|
+
session.as_str(),
|
|
460
|
+
window.as_str()
|
|
461
|
+
),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
452
464
|
Ok(SpawnResult {
|
|
453
|
-
pane_id: PaneId::new(
|
|
465
|
+
pane_id: PaneId::new(pane),
|
|
454
466
|
session: session.clone(),
|
|
455
467
|
window: window.clone(),
|
|
456
468
|
child_pid: None,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.3.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.3.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.3.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.3.7",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.3.7",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.3.7"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|