@team-agent/installer 0.3.7 → 0.3.9
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/emit.rs +132 -4
- package/crates/team-agent/src/cli/leader.rs +12 -8
- package/crates/team-agent/src/cli/mod.rs +121 -28
- package/crates/team-agent/src/cli/tests/base.rs +14 -0
- 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 +6 -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/leader/owner_bind.rs +59 -20
- package/crates/team-agent/src/leader/start.rs +34 -23
- package/crates/team-agent/src/leader/tests/identity.rs +22 -0
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +13 -0
- package/crates/team-agent/src/lifecycle/launch.rs +203 -16
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +25 -12
- package/crates/team-agent/src/lifecycle/restart.rs +7 -3
- package/crates/team-agent/src/lifecycle/tests/core.rs +192 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +112 -0
- package/crates/team-agent/src/lifecycle/types.rs +2 -0
- package/crates/team-agent/src/messaging/results.rs +27 -22
- package/crates/team-agent/src/messaging/tests/runtime.rs +18 -0
- package/crates/team-agent/src/provider/adapter.rs +177 -15
- package/crates/team-agent/src/state/identity.rs +29 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +44 -0
- package/crates/team-agent/src/tmux_backend.rs +90 -7
- 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
|
@@ -1,4 +1,41 @@
|
|
|
1
1
|
use super::*;
|
|
2
|
+
use serial_test::serial;
|
|
3
|
+
|
|
4
|
+
struct EnvUnsetGuard {
|
|
5
|
+
previous: Vec<(&'static str, Option<String>)>,
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
impl EnvUnsetGuard {
|
|
9
|
+
fn unset(keys: &[&'static str]) -> Self {
|
|
10
|
+
let previous = keys
|
|
11
|
+
.iter()
|
|
12
|
+
.map(|key| (*key, std::env::var(key).ok()))
|
|
13
|
+
.collect::<Vec<_>>();
|
|
14
|
+
for key in keys {
|
|
15
|
+
std::env::remove_var(key);
|
|
16
|
+
}
|
|
17
|
+
Self { previous }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
impl Drop for EnvUnsetGuard {
|
|
22
|
+
fn drop(&mut self) {
|
|
23
|
+
for (key, value) in self.previous.drain(..).rev() {
|
|
24
|
+
match value {
|
|
25
|
+
Some(value) => std::env::set_var(key, value),
|
|
26
|
+
None => std::env::remove_var(key),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
struct WorkspaceCleanup(std::path::PathBuf);
|
|
33
|
+
|
|
34
|
+
impl Drop for WorkspaceCleanup {
|
|
35
|
+
fn drop(&mut self) {
|
|
36
|
+
let _ = std::fs::remove_dir_all(&self.0);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
2
39
|
|
|
3
40
|
// =========================================================================
|
|
4
41
|
// WAVE-2 NON-SUB CHECKPOINT — 9 MISSING CLI subcommands (ABSENT from cli/emit.rs dispatch).
|
|
@@ -131,6 +168,36 @@ tasks:
|
|
|
131
168
|
std::fs::write(ws.join("team.spec.yaml"), spec).unwrap();
|
|
132
169
|
}
|
|
133
170
|
|
|
171
|
+
fn seed_collect_runtime_state(ws: &std::path::Path) {
|
|
172
|
+
crate::state::persist::save_runtime_state(
|
|
173
|
+
ws,
|
|
174
|
+
&json!({
|
|
175
|
+
"active_team_key": "fake-e2e",
|
|
176
|
+
"team_dir": ws.to_string_lossy().to_string(),
|
|
177
|
+
"spec_path": ws.join("team.spec.yaml").to_string_lossy().to_string(),
|
|
178
|
+
"session_name": "team-agent-fake-e2e",
|
|
179
|
+
"leader": {"id": "leader"},
|
|
180
|
+
"agents": {
|
|
181
|
+
"fake_impl": {
|
|
182
|
+
"status": "running",
|
|
183
|
+
"provider": "fake",
|
|
184
|
+
"role": "implementation_engineer",
|
|
185
|
+
"window": "fake_impl",
|
|
186
|
+
"owner_team_id": "fake-e2e"
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
"tasks": [{
|
|
190
|
+
"id": "task_impl",
|
|
191
|
+
"title": "Fake implementation",
|
|
192
|
+
"type": "implementation",
|
|
193
|
+
"status": "pending",
|
|
194
|
+
"assignee": "fake_impl"
|
|
195
|
+
}]
|
|
196
|
+
}),
|
|
197
|
+
)
|
|
198
|
+
.unwrap();
|
|
199
|
+
}
|
|
200
|
+
|
|
134
201
|
// ── sessions ── golden cli/parser.py:230 `cmd_sessions` -> runtime.sessions(ws). EXIT 0.
|
|
135
202
|
// `team-agent sessions --workspace <ws> --json` on an empty ws ->
|
|
136
203
|
// {"ok":true,"sessions":[],"workspace":"<ws>"} (--json sort_keys). RED: unrouted -> Error.
|
|
@@ -169,9 +236,25 @@ tasks:
|
|
|
169
236
|
// "delivered_messages":[],"invalid_results":[],"ok":true,"results":{...},"state_file":"<ws>/team_state.md"}
|
|
170
237
|
// RED: unrouted -> Error.
|
|
171
238
|
#[test]
|
|
239
|
+
#[serial(env)]
|
|
172
240
|
fn dispatch_routes_collect_with_spec() {
|
|
241
|
+
let _env = EnvUnsetGuard::unset(&[
|
|
242
|
+
"TEAM_AGENT_WORKSPACE",
|
|
243
|
+
"TEAM_AGENT_TEAM_ID",
|
|
244
|
+
"TEAM_AGENT_OWNER_TEAM_ID",
|
|
245
|
+
"TEAM_AGENT_ACTIVE_TEAM",
|
|
246
|
+
"TEAM_AGENT_ID",
|
|
247
|
+
"TEAM_AGENT_LEADER_PANE_ID",
|
|
248
|
+
"TEAM_AGENT_LEADER_SESSION_UUID",
|
|
249
|
+
"TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE",
|
|
250
|
+
"TEAM_AGENT_LEADER_PROVIDER",
|
|
251
|
+
"TMUX",
|
|
252
|
+
"TMUX_PANE",
|
|
253
|
+
]);
|
|
173
254
|
let ws = tmp_workspace();
|
|
255
|
+
let _cleanup = WorkspaceCleanup(ws.clone());
|
|
174
256
|
seed_team_spec(&ws);
|
|
257
|
+
seed_collect_runtime_state(&ws);
|
|
175
258
|
let code = run(&cli_argv(&["collect", "--workspace", &ws.to_string_lossy(), "--json"]), &ws);
|
|
176
259
|
assert_eq!(
|
|
177
260
|
code,
|
|
@@ -180,7 +263,6 @@ tasks:
|
|
|
180
263
|
{{collected,collected_results,coordinator,delivered_messages,invalid_results,ok,results,state_file}}; \
|
|
181
264
|
today -> unknown-subcommand Error"
|
|
182
265
|
);
|
|
183
|
-
let _ = std::fs::remove_dir_all(&ws);
|
|
184
266
|
}
|
|
185
267
|
|
|
186
268
|
// ── repair-state ── golden parser.py:303 `cmd_repair_state` -> runtime.repair_state (quick_start.py:285).
|
|
@@ -264,9 +264,13 @@ fn current_uid() -> Option<String> {
|
|
|
264
264
|
json: true,
|
|
265
265
|
};
|
|
266
266
|
let text = outcome_text(cmd_restart(&args));
|
|
267
|
+
// RED-2-STILL: entry gate now resolves via canonical_run_workspace; for a no-spec workspace
|
|
268
|
+
// the "no spec" surfacing may come from either the entry gate ("missing spec for restart")
|
|
269
|
+
// or, when a stray ancestor .team lets it past, the resolve gate ("active team spec not
|
|
270
|
+
// found"). Both are the REAL crate::lifecycle error (not a placeholder ok:true).
|
|
267
271
|
assert!(
|
|
268
|
-
text.contains("missing spec for restart"),
|
|
269
|
-
"cmd_restart must surface the REAL crate::lifecycle::restart
|
|
272
|
+
text.contains("missing spec for restart") || text.contains("active team spec not found"),
|
|
273
|
+
"cmd_restart must surface the REAL crate::lifecycle::restart missing-spec error \
|
|
270
274
|
(not the placeholder's canned ok:true); got {text}"
|
|
271
275
|
);
|
|
272
276
|
}
|
|
@@ -1,39 +1,104 @@
|
|
|
1
|
-
//!
|
|
1
|
+
//! E12 (P0) · `sessions_to_kill` 纯决策单测(kill 决策下沉)。
|
|
2
2
|
//!
|
|
3
|
-
//!
|
|
4
|
-
//!
|
|
3
|
+
//! spare = state 锚 session(anchor_sessions) ∪ `team-agent-leader-` 命名前缀(并集,锚优先)。
|
|
4
|
+
//! 独享 socket(无 spare)才允许整 server 拆;共享/leader 在 → 逐 session kill。
|
|
5
|
+
//! 集成面由 tests/b5_leader_terminal_kill_red.rs 的真 tmux 契约覆盖,此处锁纯决策 + 4 反向 case。
|
|
5
6
|
|
|
6
|
-
use crate::cli::lifecycle_port::
|
|
7
|
+
use crate::cli::lifecycle_port::{sessions_to_kill, KillDecision};
|
|
7
8
|
use crate::transport::SessionName;
|
|
9
|
+
use std::collections::BTreeSet;
|
|
8
10
|
|
|
9
11
|
fn names(raw: &[&str]) -> Vec<SessionName> {
|
|
10
12
|
raw.iter().map(|name| SessionName::new(*name)).collect()
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
fn anchors(raw: &[&str]) -> BTreeSet<String> {
|
|
16
|
+
raw.iter().map(|s| s.to_string()).collect()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// RC-4(独享 socket):仅目标 session(无 spare)→ 整 server 拆。
|
|
20
|
+
#[test]
|
|
21
|
+
fn rc4_exclusive_socket_kills_server() {
|
|
22
|
+
assert_eq!(
|
|
23
|
+
sessions_to_kill(&names(&["team-x", "team-y"]), &BTreeSet::new()),
|
|
24
|
+
KillDecision::KillServerExclusive
|
|
25
|
+
);
|
|
26
|
+
// 空 session 集 → 逐 kill(no-op),不整 server 拆(没东西可拆)。
|
|
27
|
+
assert_eq!(
|
|
28
|
+
sessions_to_kill(&[], &BTreeSet::new()),
|
|
29
|
+
KillDecision::KillIndividually { to_kill: vec![], spared: vec![] }
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// RC-1(本 P0 复现):in_tmux leader 在用户 session(**无前缀**),靠 state 锚 spare → 用户 session 存活。
|
|
13
34
|
#[test]
|
|
14
|
-
fn
|
|
15
|
-
|
|
16
|
-
|
|
35
|
+
fn rc1_in_tmux_no_prefix_anchor_spares_user_session() {
|
|
36
|
+
let sessions = names(&["team-coder-team", "team-x"]); // 用户 session 无 leader 前缀
|
|
37
|
+
let anchor = anchors(&["team-coder-team"]); // state 锚 pane 所在 session
|
|
38
|
+
let decision = sessions_to_kill(&sessions, &anchor);
|
|
39
|
+
match decision {
|
|
40
|
+
KillDecision::KillIndividually { to_kill, spared } => {
|
|
41
|
+
assert_eq!(to_kill, names(&["team-x"]), "only non-anchor session killed");
|
|
42
|
+
assert_eq!(spared, names(&["team-coder-team"]), "user/leader session spared by anchor");
|
|
43
|
+
}
|
|
44
|
+
other => panic!("anchor session must force per-session kill, not {other:?}"),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 前缀判据仍生效(并集):leader 前缀 session spare,即使无 state 锚。
|
|
49
|
+
#[test]
|
|
50
|
+
fn prefix_session_spared_without_anchor() {
|
|
51
|
+
let sessions = names(&["team-agent-leader-claude-ws-deadbeef", "team-x"]);
|
|
52
|
+
let decision = sessions_to_kill(&sessions, &BTreeSet::new());
|
|
53
|
+
match decision {
|
|
54
|
+
KillDecision::KillIndividually { to_kill, spared } => {
|
|
55
|
+
assert_eq!(to_kill, names(&["team-x"]));
|
|
56
|
+
assert_eq!(spared, names(&["team-agent-leader-claude-ws-deadbeef"]));
|
|
57
|
+
}
|
|
58
|
+
other => panic!("prefix session must spare, not {other:?}"),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// RC-2(state 损坏无锚):anchor_sessions 空 → 退命名前缀判据(此处仅前缀 spare;无前缀则全 kill)。
|
|
63
|
+
// (spare_fallback_to_naming event 在 anchor_anchor_sessions 发,本纯函数只验退化后的决策。)
|
|
64
|
+
#[test]
|
|
65
|
+
fn rc2_no_anchor_falls_back_to_naming() {
|
|
66
|
+
// 无锚 + 有前缀 leader → 前缀 spare。
|
|
67
|
+
let with_leader = sessions_to_kill(
|
|
68
|
+
&names(&["team-agent-leader-codex-ws-cafe", "team-x"]),
|
|
69
|
+
&BTreeSet::new(),
|
|
70
|
+
);
|
|
71
|
+
assert!(matches!(with_leader, KillDecision::KillIndividually { .. }));
|
|
72
|
+
// 无锚 + 无前缀(真损坏且 in_tmux 无前缀)→ 无 spare → 独享拆(退化兜底,与历史一致)。
|
|
73
|
+
assert_eq!(
|
|
74
|
+
sessions_to_kill(&names(&["team-x"]), &BTreeSet::new()),
|
|
75
|
+
KillDecision::KillServerExclusive
|
|
76
|
+
);
|
|
17
77
|
}
|
|
18
78
|
|
|
79
|
+
// RC-3(共享 socket):目标 2 session + 用户 1 session(锚)→ 只 kill 目标 2,用户存活,不整 server 拆。
|
|
19
80
|
#[test]
|
|
20
|
-
fn
|
|
21
|
-
let sessions = names(&[
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
81
|
+
fn rc3_shared_socket_kills_only_target_sessions() {
|
|
82
|
+
let sessions = names(&["team-a", "team-b", "user-shell"]);
|
|
83
|
+
let anchor = anchors(&["user-shell"]);
|
|
84
|
+
let decision = sessions_to_kill(&sessions, &anchor);
|
|
85
|
+
match decision {
|
|
86
|
+
KillDecision::KillIndividually { to_kill, spared } => {
|
|
87
|
+
assert_eq!(to_kill, names(&["team-a", "team-b"]));
|
|
88
|
+
assert_eq!(spared, names(&["user-shell"]));
|
|
89
|
+
}
|
|
90
|
+
other => panic!("shared socket must not whole-server kill, got {other:?}"),
|
|
91
|
+
}
|
|
29
92
|
}
|
|
30
93
|
|
|
94
|
+
// 并集语义:同一 session 既前缀又锚 → spare 一次(不重复)。
|
|
31
95
|
#[test]
|
|
32
|
-
fn
|
|
33
|
-
let sessions = names(&["team-agent-leader-
|
|
96
|
+
fn union_prefix_and_anchor_no_double_count() {
|
|
97
|
+
let sessions = names(&["team-agent-leader-claude-ws-beef"]);
|
|
98
|
+
let anchor = anchors(&["team-agent-leader-claude-ws-beef"]);
|
|
99
|
+
let decision = sessions_to_kill(&sessions, &anchor);
|
|
34
100
|
assert_eq!(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"a socket holding only leader sessions must not be torn down"
|
|
101
|
+
decision,
|
|
102
|
+
KillDecision::KillIndividually { to_kill: vec![], spared: names(&["team-agent-leader-claude-ws-beef"]) }
|
|
38
103
|
);
|
|
39
104
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
// RED-1 根治:install-skill 必须作为真 CLI 子命令存在(此前 packaging::install_skill 未接进
|
|
4
|
+
// 任何 dispatch=双重死代码,install.mjs 走自己的 JS 拷贝逻辑漏 copilot)。
|
|
5
|
+
// 这里钉:① 子命令路由 ② --target all 默认 ③ --source 必需 ④ 缺 source 显式 Usage 错。
|
|
6
|
+
|
|
7
|
+
fn skill_source() -> PathBuf {
|
|
8
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
9
|
+
static CTR: AtomicU64 = AtomicU64::new(0);
|
|
10
|
+
let dir = std::env::temp_dir().join(format!(
|
|
11
|
+
"ta-cli-installskill-src-{}-{}",
|
|
12
|
+
std::process::id(),
|
|
13
|
+
CTR.fetch_add(1, Ordering::Relaxed)
|
|
14
|
+
));
|
|
15
|
+
std::fs::create_dir_all(dir.join("team-agent")).unwrap();
|
|
16
|
+
std::fs::write(dir.join("team-agent").join("SKILL.md"), b"---\nname: team-agent\n---\nbody\n").unwrap();
|
|
17
|
+
dir.join("team-agent")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#[test]
|
|
21
|
+
fn dispatch_routes_install_skill_dry_run_all() {
|
|
22
|
+
let ws = tmp_workspace();
|
|
23
|
+
let source = skill_source();
|
|
24
|
+
// --dry-run 不落地(不碰真实 HOME),只验路由 + exit 0。
|
|
25
|
+
let code = run(
|
|
26
|
+
&[
|
|
27
|
+
"install-skill".to_string(),
|
|
28
|
+
"--target".to_string(),
|
|
29
|
+
"all".to_string(),
|
|
30
|
+
"--source".to_string(),
|
|
31
|
+
source.to_string_lossy().to_string(),
|
|
32
|
+
"--dry-run".to_string(),
|
|
33
|
+
"--json".to_string(),
|
|
34
|
+
],
|
|
35
|
+
&ws,
|
|
36
|
+
);
|
|
37
|
+
assert_eq!(code, ExitCode::Ok, "`install-skill --target all --source <dir> --dry-run` must route + exit 0");
|
|
38
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
39
|
+
let _ = std::fs::remove_dir_all(source.parent().unwrap());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#[test]
|
|
43
|
+
fn install_skill_missing_source_is_usage_error() {
|
|
44
|
+
let ws = tmp_workspace();
|
|
45
|
+
// 无 --source → Usage 错(exit 2),不静默。
|
|
46
|
+
let code = run(
|
|
47
|
+
&[
|
|
48
|
+
"install-skill".to_string(),
|
|
49
|
+
"--target".to_string(),
|
|
50
|
+
"all".to_string(),
|
|
51
|
+
"--json".to_string(),
|
|
52
|
+
],
|
|
53
|
+
&ws,
|
|
54
|
+
);
|
|
55
|
+
assert_eq!(code, ExitCode::Error, "install-skill without --source must error (CliError::Usage -> emit_cli_error exit 1)");
|
|
56
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[test]
|
|
60
|
+
fn install_skill_invalid_target_is_usage_error() {
|
|
61
|
+
let ws = tmp_workspace();
|
|
62
|
+
let source = skill_source();
|
|
63
|
+
let code = run(
|
|
64
|
+
&[
|
|
65
|
+
"install-skill".to_string(),
|
|
66
|
+
"--target".to_string(),
|
|
67
|
+
"bogus".to_string(),
|
|
68
|
+
"--source".to_string(),
|
|
69
|
+
source.to_string_lossy().to_string(),
|
|
70
|
+
],
|
|
71
|
+
&ws,
|
|
72
|
+
);
|
|
73
|
+
assert_eq!(code, ExitCode::Error, "install-skill --target bogus must error (CliError::Usage -> emit_cli_error exit 1)");
|
|
74
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
75
|
+
let _ = std::fs::remove_dir_all(source.parent().unwrap());
|
|
76
|
+
}
|
|
@@ -189,15 +189,29 @@ fn bind_provider_from_env_or_command(command: &str) -> Provider {
|
|
|
189
189
|
std::env::var("TEAM_AGENT_LEADER_PROVIDER")
|
|
190
190
|
.ok()
|
|
191
191
|
.and_then(|raw| super::helpers::parse_provider(&raw))
|
|
192
|
-
.
|
|
192
|
+
.or_else(|| provider_from_command(command))
|
|
193
|
+
// E11 层2:未知命令不再静默默认 codex(会误绑任意 provider + 喂错分类器)。
|
|
194
|
+
// 无法识别时回落 Codex 仅作最末兜底,且该路径已被 provider_from_command 的显式 None 收窄
|
|
195
|
+
// (调用方理应只在已知 leader 命令上 bind);保留以不改 fn 签名/上游 panic 面。
|
|
196
|
+
.unwrap_or(Provider::Codex)
|
|
193
197
|
}
|
|
194
198
|
|
|
195
|
-
|
|
199
|
+
/// E11 层2 + N39:command 名 → wire 串 → `parse_provider`(**单一映射源**,与
|
|
200
|
+
/// `owner_bind_provider_wire` 共用 [`command_provider_wire`])。未知命令 → `None`
|
|
201
|
+
/// (危险的 `_ => Codex` 默认已删:不静默把任意 provider 误绑成 codex)。
|
|
202
|
+
fn provider_from_command(command: &str) -> Option<Provider> {
|
|
203
|
+
command_provider_wire(command).and_then(super::helpers::parse_provider)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/// command 名 → provider wire 串(单一真相;copilot/claude/codex/fake)。未知 → `None`。
|
|
207
|
+
/// `claude.exe` 归一为 `claude`。
|
|
208
|
+
fn command_provider_wire(command: &str) -> Option<&'static str> {
|
|
196
209
|
match exact_command_name(command).as_deref() {
|
|
197
|
-
Some("claude") | Some("claude.exe") =>
|
|
198
|
-
Some("codex") =>
|
|
199
|
-
Some("
|
|
200
|
-
|
|
210
|
+
Some("claude") | Some("claude.exe") => Some("claude"),
|
|
211
|
+
Some("codex") => Some("codex"),
|
|
212
|
+
Some("copilot") => Some("copilot"),
|
|
213
|
+
Some("fake") => Some("fake"),
|
|
214
|
+
_ => None,
|
|
201
215
|
}
|
|
202
216
|
}
|
|
203
217
|
|
|
@@ -214,21 +228,15 @@ fn exact_command_name(command: &str) -> Option<String> {
|
|
|
214
228
|
|
|
215
229
|
pub fn owner_bind_provider_wire(command: &str) -> &'static str {
|
|
216
230
|
if let Ok(raw) = std::env::var("TEAM_AGENT_LEADER_PROVIDER") {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
"
|
|
222
|
-
"fake" => "fake",
|
|
223
|
-
_ => "",
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
match exact_command_name(command).as_deref() {
|
|
227
|
-
Some("claude") | Some("claude.exe") => "claude",
|
|
228
|
-
Some("codex") => "codex",
|
|
229
|
-
Some("fake") => "fake",
|
|
230
|
-
_ => "",
|
|
231
|
+
// env 显式 provider:经 parse_provider(单一表,知 copilot)校验后透传其 wire 串;
|
|
232
|
+
// 不识别 → ""(空,与原行为一致:不绑)。
|
|
233
|
+
return super::helpers::parse_provider(&raw)
|
|
234
|
+
.map(super::helpers::provider_wire)
|
|
235
|
+
.unwrap_or("");
|
|
231
236
|
}
|
|
237
|
+
// E11 层2 + N39:与 provider_from_command 共用 command_provider_wire 单一映射(含 copilot);
|
|
238
|
+
// 未知命令 → ""(不绑),不再静默当 codex。
|
|
239
|
+
command_provider_wire(command).unwrap_or("")
|
|
232
240
|
}
|
|
233
241
|
|
|
234
242
|
fn family_a_identity(
|
|
@@ -290,3 +298,34 @@ fn tmux_pane_current_command(workspace: &Path, pane: &str) -> Result<String, Lea
|
|
|
290
298
|
// NOTE: `derive_leader_session_uuid`(`leader_binding.py:146`)已由
|
|
291
299
|
// `model::ids::LeaderSessionUuid::derive` 字节对齐实现(含 NUL 拒绝 + golden 测试)——
|
|
292
300
|
// 此 lane REUSE 之,不重声明。
|
|
301
|
+
|
|
302
|
+
#[cfg(test)]
|
|
303
|
+
mod e11_provider_bind_tests {
|
|
304
|
+
#![allow(clippy::unwrap_used)]
|
|
305
|
+
use super::*;
|
|
306
|
+
|
|
307
|
+
// E11 层2:copilot leader 命令必须绑成 Provider::Copilot(此前缺臂 → _ => Codex 误绑)。
|
|
308
|
+
#[test]
|
|
309
|
+
fn copilot_command_binds_copilot_not_codex() {
|
|
310
|
+
assert_eq!(provider_from_command("copilot --banner -C /ws"), Some(Provider::Copilot));
|
|
311
|
+
assert_eq!(provider_from_command("/opt/homebrew/bin/copilot"), Some(Provider::Copilot));
|
|
312
|
+
assert_eq!(owner_bind_provider_wire("copilot --banner"), "copilot");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#[test]
|
|
316
|
+
fn known_commands_map_via_single_source() {
|
|
317
|
+
assert_eq!(provider_from_command("claude"), Some(Provider::Claude));
|
|
318
|
+
assert_eq!(provider_from_command("codex"), Some(Provider::Codex));
|
|
319
|
+
assert_eq!(provider_from_command("fake"), Some(Provider::Fake));
|
|
320
|
+
assert_eq!(owner_bind_provider_wire("claude"), "claude");
|
|
321
|
+
assert_eq!(owner_bind_provider_wire("codex"), "codex");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// E11 层2:未知命令不再静默默认 codex —— provider_from_command → None,wire → ""。
|
|
325
|
+
#[test]
|
|
326
|
+
fn unknown_command_is_none_not_silent_codex() {
|
|
327
|
+
assert_eq!(provider_from_command("node /some/thing.js"), None);
|
|
328
|
+
assert_eq!(provider_from_command("totally-unknown"), None);
|
|
329
|
+
assert_eq!(owner_bind_provider_wire("totally-unknown"), "");
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -14,8 +14,8 @@ use super::helpers::{
|
|
|
14
14
|
};
|
|
15
15
|
use super::owner_bind::leader_identity_context;
|
|
16
16
|
use super::{
|
|
17
|
-
LeaderError, LeaderLaunchOutcome, LeaderLaunchSocket, LeaderLaunchStatus,
|
|
18
|
-
LeaderStartPlan,
|
|
17
|
+
LeaderError, LeaderIdentity, LeaderLaunchOutcome, LeaderLaunchSocket, LeaderLaunchStatus,
|
|
18
|
+
LeaderStartMode, LeaderStartPlan,
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
// ── leader::start — leader_start_plan / start_leader / session 名 ──
|
|
@@ -72,27 +72,7 @@ pub fn leader_start_plan(
|
|
|
72
72
|
} else {
|
|
73
73
|
LeaderStartMode::NewTmuxSession
|
|
74
74
|
};
|
|
75
|
-
let
|
|
76
|
-
leader_env.insert(
|
|
77
|
-
"TEAM_AGENT_LEADER_PROVIDER".to_string(),
|
|
78
|
-
provider_wire(provider).to_string(),
|
|
79
|
-
);
|
|
80
|
-
leader_env.insert(
|
|
81
|
-
"TEAM_AGENT_LEADER_SESSION_UUID".to_string(),
|
|
82
|
-
identity.leader_session_uuid.as_str().to_string(),
|
|
83
|
-
);
|
|
84
|
-
leader_env.insert(
|
|
85
|
-
"TEAM_AGENT_MACHINE_FINGERPRINT".to_string(),
|
|
86
|
-
identity.machine_fingerprint.clone(),
|
|
87
|
-
);
|
|
88
|
-
leader_env.insert(
|
|
89
|
-
"TEAM_AGENT_WORKSPACE".to_string(),
|
|
90
|
-
identity.workspace_abspath.to_string_lossy().into_owned(),
|
|
91
|
-
);
|
|
92
|
-
leader_env.insert(
|
|
93
|
-
"TEAM_AGENT_TEAM_ID".to_string(),
|
|
94
|
-
identity.team_id.as_str().to_string(),
|
|
95
|
-
);
|
|
75
|
+
let leader_env = leader_env_for_identity(provider, &identity);
|
|
96
76
|
let argv = start_argv(
|
|
97
77
|
mode,
|
|
98
78
|
provider,
|
|
@@ -119,6 +99,37 @@ pub fn leader_start_plan(
|
|
|
119
99
|
})
|
|
120
100
|
}
|
|
121
101
|
|
|
102
|
+
pub(crate) fn leader_env_for_identity(
|
|
103
|
+
provider: Provider,
|
|
104
|
+
identity: &LeaderIdentity,
|
|
105
|
+
) -> BTreeMap<String, String> {
|
|
106
|
+
let mut leader_env = BTreeMap::new();
|
|
107
|
+
leader_env.insert(
|
|
108
|
+
"TEAM_AGENT_LEADER_PROVIDER".to_string(),
|
|
109
|
+
provider_wire(provider).to_string(),
|
|
110
|
+
);
|
|
111
|
+
leader_env.insert(
|
|
112
|
+
"TEAM_AGENT_LEADER_SESSION_UUID".to_string(),
|
|
113
|
+
identity.leader_session_uuid.as_str().to_string(),
|
|
114
|
+
);
|
|
115
|
+
leader_env.insert(
|
|
116
|
+
"TEAM_AGENT_MACHINE_FINGERPRINT".to_string(),
|
|
117
|
+
identity.machine_fingerprint.clone(),
|
|
118
|
+
);
|
|
119
|
+
leader_env.insert(
|
|
120
|
+
"TEAM_AGENT_WORKSPACE".to_string(),
|
|
121
|
+
identity.workspace_abspath.to_string_lossy().into_owned(),
|
|
122
|
+
);
|
|
123
|
+
leader_env.insert(
|
|
124
|
+
"TEAM_AGENT_TEAM_ID".to_string(),
|
|
125
|
+
identity.team_id.as_str().to_string(),
|
|
126
|
+
);
|
|
127
|
+
if provider == Provider::Copilot {
|
|
128
|
+
leader_env.insert("COPILOT_DISABLE_TERMINAL_TITLE".to_string(), "1".to_string());
|
|
129
|
+
}
|
|
130
|
+
leader_env
|
|
131
|
+
}
|
|
132
|
+
|
|
122
133
|
/// `start_leader`(card §46;`__init__.py:60`)。计算并执行 leader 启动计划(spawn + 信号处理)。
|
|
123
134
|
/// 进程退出后 `Err`/退出码经 caller 处理(此处返 `Result` 替代 Python 的 `SystemExit`)。
|
|
124
135
|
pub fn start_leader(
|
|
@@ -90,6 +90,28 @@ use super::*;
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
#[test]
|
|
94
|
+
#[serial_test::serial(env)]
|
|
95
|
+
fn copilot_leader_env_disables_terminal_title_only_for_copilot() {
|
|
96
|
+
let ws = std::env::temp_dir().join(format!("ta_rs_lsp_copilot_{}", std::process::id()));
|
|
97
|
+
std::fs::create_dir_all(&ws).unwrap();
|
|
98
|
+
let identity = leader_identity_context(&ws, None, Some(&serde_json::json!({}))).unwrap();
|
|
99
|
+
|
|
100
|
+
let copilot = leader_env_for_identity(Provider::Copilot, &identity);
|
|
101
|
+
assert_eq!(
|
|
102
|
+
copilot.get("COPILOT_DISABLE_TERMINAL_TITLE").map(String::as_str),
|
|
103
|
+
Some("1")
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
for provider in [Provider::Codex, Provider::ClaudeCode] {
|
|
107
|
+
let leader_env = leader_env_for_identity(provider, &identity);
|
|
108
|
+
assert!(
|
|
109
|
+
!leader_env.contains_key("COPILOT_DISABLE_TERMINAL_TITLE"),
|
|
110
|
+
"{provider:?} leader env must not include copilot title override"
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
// ═══════════════ P2 FIX-LOOP RED (复绿即对抗 cross-model findings) ═══════════════
|
|
94
116
|
// Lock CORRECT Python v0.2.11 leader-identity behavior the contracts missed.
|
|
95
117
|
// Golden re-probed via /tmp/probe_p2_leader.py vs team-agent-public @ 439bef8
|
|
@@ -134,6 +134,19 @@ use super::*;
|
|
|
134
134
|
assert!(got.as_str().starts_with("team-agent-leader-claude_code-proj-"), "got {}", got.as_str());
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
#[test]
|
|
138
|
+
fn leader_session_name_uses_copilot_provider_string() {
|
|
139
|
+
let base = std::env::temp_dir().join(format!("ta_rs_lsn_copilot_{}", std::process::id()));
|
|
140
|
+
let dir = base.join("proj");
|
|
141
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
142
|
+
let got = leader_session_name(Provider::Copilot, &dir);
|
|
143
|
+
assert!(
|
|
144
|
+
got.as_str().starts_with("team-agent-leader-copilot-proj-"),
|
|
145
|
+
"got {}",
|
|
146
|
+
got.as_str()
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
137
150
|
// =====================================================================
|
|
138
151
|
// 6. Family A 正源 owner 绑定 — bind_owner_from_caller_pane(unimplemented → RED)
|
|
139
152
|
// =====================================================================
|