@team-agent/installer 0.3.7 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +112 -0
- package/crates/team-agent/src/cli/mod.rs +121 -28
- 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/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/lifecycle/launch.rs +203 -16
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +16 -10
- package/crates/team-agent/src/lifecycle/tests/core.rs +192 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +36 -0
- package/crates/team-agent/src/lifecycle/types.rs +2 -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,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
|
+
}
|
|
@@ -768,28 +768,165 @@ fn env_nonempty(key: &str) -> bool {
|
|
|
768
768
|
/// action : `unset TEAM_AGENT_LEADER_PANE_ID, or set it to a live tmux pane id`
|
|
769
769
|
/// log : `TEAM_AGENT_LEADER_PANE_ID=%<id>`
|
|
770
770
|
/// env 未设(或空)→ Ok(())。
|
|
771
|
-
/// env 设了但
|
|
772
|
-
///
|
|
771
|
+
/// env 设了但 pane 可判定为 Dead/Absent → Err(RequirementUnmet)。
|
|
772
|
+
/// 真实 tmux 后端跨所有现存 tmux socket server 探测:TEAM_AGENT_LEADER_PANE_ID 是用户
|
|
773
|
+
/// override 指针,不归属当前 team socket。
|
|
774
|
+
/// probe 返 Unknown 不挡(被动路径降级):本主动路径只对【显式 Dead/Absent】fail-fast,
|
|
773
775
|
/// MUST-17 不过度设计 / unset 走 pass-through(b7_unset_leader_pane_env_passes_through 守)。
|
|
774
776
|
pub(crate) fn validate_active_leader_pane_env(
|
|
775
777
|
transport: &dyn Transport,
|
|
778
|
+
) -> Result<(), LifecycleError> {
|
|
779
|
+
validate_active_leader_pane_env_with_workspaces(transport, &[])
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
pub(crate) fn validate_active_leader_pane_env_with_workspace(
|
|
783
|
+
transport: &dyn Transport,
|
|
784
|
+
workspace: Option<&Path>,
|
|
785
|
+
) -> Result<(), LifecycleError> {
|
|
786
|
+
let workspaces = workspace.into_iter().collect::<Vec<_>>();
|
|
787
|
+
validate_active_leader_pane_env_with_workspaces(transport, &workspaces)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
pub(crate) fn validate_active_leader_pane_env_with_workspaces(
|
|
791
|
+
transport: &dyn Transport,
|
|
792
|
+
workspaces: &[&Path],
|
|
776
793
|
) -> Result<(), LifecycleError> {
|
|
777
794
|
let pane_id_raw = match std::env::var("TEAM_AGENT_LEADER_PANE_ID") {
|
|
778
795
|
Ok(v) if !v.is_empty() => v,
|
|
779
796
|
_ => return Ok(()),
|
|
780
797
|
};
|
|
781
|
-
let
|
|
782
|
-
|
|
783
|
-
|
|
798
|
+
let pane = crate::transport::PaneId::new(&pane_id_raw);
|
|
799
|
+
if !is_tmux_pane_id_format(&pane) {
|
|
800
|
+
write_invalid_leader_pane_env_warning(workspaces, &pane_id_raw);
|
|
784
801
|
return Ok(());
|
|
785
802
|
}
|
|
803
|
+
let failure = match leader_pane_env_state_for_validation(transport, &pane) {
|
|
804
|
+
LeaderPaneEnvState::Dead => Some("dead"),
|
|
805
|
+
LeaderPaneEnvState::Absent => Some("absent"),
|
|
806
|
+
LeaderPaneEnvState::Live | LeaderPaneEnvState::Unknown => None,
|
|
807
|
+
};
|
|
808
|
+
let Some(reason) = failure else {
|
|
809
|
+
return Ok(());
|
|
810
|
+
};
|
|
786
811
|
Err(LifecycleError::RequirementUnmet(format!(
|
|
787
|
-
"TEAM_AGENT_LEADER_PANE_ID points at a
|
|
812
|
+
"TEAM_AGENT_LEADER_PANE_ID points at a {reason} pane: {pane_id_raw}\n\
|
|
788
813
|
action: unset TEAM_AGENT_LEADER_PANE_ID, or set it to a live tmux pane id\n\
|
|
789
814
|
log: TEAM_AGENT_LEADER_PANE_ID={pane_id_raw}"
|
|
790
815
|
)))
|
|
791
816
|
}
|
|
792
817
|
|
|
818
|
+
fn write_invalid_leader_pane_env_warning(workspaces: &[&Path], pane_id_raw: &str) {
|
|
819
|
+
let message = "invalid pane id format, skipping validation";
|
|
820
|
+
let mut wrote = false;
|
|
821
|
+
let mut errors = Vec::new();
|
|
822
|
+
let mut seen = BTreeSet::new();
|
|
823
|
+
for workspace in workspaces {
|
|
824
|
+
let key = workspace.to_string_lossy().to_string();
|
|
825
|
+
if !seen.insert(key.clone()) {
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
match crate::event_log::EventLog::new(workspace).write(
|
|
829
|
+
"leader_pane_env.validation_warning",
|
|
830
|
+
serde_json::json!({
|
|
831
|
+
"env": "TEAM_AGENT_LEADER_PANE_ID",
|
|
832
|
+
"value": pane_id_raw,
|
|
833
|
+
"warning": message,
|
|
834
|
+
}),
|
|
835
|
+
) {
|
|
836
|
+
Ok(_) => wrote = true,
|
|
837
|
+
Err(err) => errors.push(format!("{key}: {err}")),
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if !wrote {
|
|
841
|
+
eprintln!("TEAM_AGENT_LEADER_PANE_ID={pane_id_raw}: {message}");
|
|
842
|
+
if !errors.is_empty() {
|
|
843
|
+
eprintln!(
|
|
844
|
+
"TEAM_AGENT_LEADER_PANE_ID warning event write failed: {}",
|
|
845
|
+
errors.join("; ")
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
852
|
+
pub(crate) enum LeaderPaneEnvState {
|
|
853
|
+
Live,
|
|
854
|
+
Dead,
|
|
855
|
+
Absent,
|
|
856
|
+
Unknown,
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
fn leader_pane_env_state_for_validation(
|
|
860
|
+
transport: &dyn Transport,
|
|
861
|
+
pane: &crate::transport::PaneId,
|
|
862
|
+
) -> LeaderPaneEnvState {
|
|
863
|
+
if !is_tmux_pane_id_format(pane) {
|
|
864
|
+
return LeaderPaneEnvState::Unknown;
|
|
865
|
+
}
|
|
866
|
+
if transport.probes_real_tmux_socket_roots() {
|
|
867
|
+
return active_leader_pane_state_across_tmux_sockets(pane);
|
|
868
|
+
}
|
|
869
|
+
active_leader_pane_state(transport, pane)
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
fn is_tmux_pane_id_format(pane: &crate::transport::PaneId) -> bool {
|
|
873
|
+
let pane = pane.as_str();
|
|
874
|
+
pane.len() > 1 && pane.starts_with('%') && pane[1..].chars().all(|ch| ch.is_ascii_digit())
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
fn active_leader_pane_state_across_tmux_sockets(
|
|
878
|
+
pane: &crate::transport::PaneId,
|
|
879
|
+
) -> LeaderPaneEnvState {
|
|
880
|
+
let endpoints = crate::tmux_backend::tmux_socket_endpoints();
|
|
881
|
+
let transports = endpoints
|
|
882
|
+
.iter()
|
|
883
|
+
.map(|endpoint| crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint))
|
|
884
|
+
.collect::<Vec<_>>();
|
|
885
|
+
active_leader_pane_state_across_transports(
|
|
886
|
+
transports.iter().map(|transport| transport as &dyn Transport),
|
|
887
|
+
pane,
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
pub(crate) fn active_leader_pane_state_across_transports<'a>(
|
|
892
|
+
transports: impl IntoIterator<Item = &'a dyn Transport>,
|
|
893
|
+
pane: &crate::transport::PaneId,
|
|
894
|
+
) -> LeaderPaneEnvState {
|
|
895
|
+
let mut found_absent = false;
|
|
896
|
+
let mut found_dead = false;
|
|
897
|
+
for transport in transports {
|
|
898
|
+
match active_leader_pane_state(transport, pane) {
|
|
899
|
+
LeaderPaneEnvState::Live => return LeaderPaneEnvState::Live,
|
|
900
|
+
LeaderPaneEnvState::Dead => found_dead = true,
|
|
901
|
+
LeaderPaneEnvState::Absent => found_absent = true,
|
|
902
|
+
LeaderPaneEnvState::Unknown => {}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if found_dead {
|
|
906
|
+
LeaderPaneEnvState::Dead
|
|
907
|
+
} else if found_absent {
|
|
908
|
+
LeaderPaneEnvState::Absent
|
|
909
|
+
} else {
|
|
910
|
+
LeaderPaneEnvState::Unknown
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
fn active_leader_pane_state(
|
|
915
|
+
transport: &dyn Transport,
|
|
916
|
+
pane: &crate::transport::PaneId,
|
|
917
|
+
) -> LeaderPaneEnvState {
|
|
918
|
+
match transport.has_pane(pane) {
|
|
919
|
+
Ok(Some(true)) => return LeaderPaneEnvState::Live,
|
|
920
|
+
Ok(Some(false)) => return LeaderPaneEnvState::Absent,
|
|
921
|
+
Ok(None) | Err(_) => {}
|
|
922
|
+
}
|
|
923
|
+
match transport.liveness(pane) {
|
|
924
|
+
Ok(crate::transport::PaneLiveness::Live) => LeaderPaneEnvState::Live,
|
|
925
|
+
Ok(crate::transport::PaneLiveness::Dead) => LeaderPaneEnvState::Dead,
|
|
926
|
+
Ok(crate::transport::PaneLiveness::Unknown) | Err(_) => LeaderPaneEnvState::Unknown,
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
793
930
|
fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &str) {
|
|
794
931
|
let Some(owner) = unbound_launched_owner(launched, launched_key) else {
|
|
795
932
|
return;
|
|
@@ -1884,7 +2021,9 @@ pub fn quick_start_with_transport_in_workspace(
|
|
|
1884
2021
|
// owner_bind / lease / display 任一消费点。被动路径(display/seed 等)各自走
|
|
1885
2022
|
// 降级+event,不在这里挡。错误三行式:error(含 pane id 字面)/action(unset
|
|
1886
2023
|
// 或修 env)/log(env var 名)。
|
|
1887
|
-
|
|
2024
|
+
let team_workspace = team_workspace(agents_dir);
|
|
2025
|
+
let warning_workspaces = [workspace, team_workspace.as_path()];
|
|
2026
|
+
validate_active_leader_pane_env_with_workspaces(transport, &warning_workspaces)?;
|
|
1888
2027
|
if !agents_dir.exists() {
|
|
1889
2028
|
return Err(LifecycleError::Compile(format!(
|
|
1890
2029
|
"agents dir not found: {}",
|
|
@@ -1920,18 +2059,40 @@ pub fn quick_start_with_transport_in_workspace(
|
|
|
1920
2059
|
.as_deref()
|
|
1921
2060
|
.is_none_or(|team| runtime_state_has_quick_start_team(&state, team))
|
|
1922
2061
|
{
|
|
2062
|
+
let session_name = state
|
|
2063
|
+
.get("session_name")
|
|
2064
|
+
.and_then(serde_json::Value::as_str)
|
|
2065
|
+
.filter(|s| !s.is_empty())
|
|
2066
|
+
.map(SessionName::new);
|
|
2067
|
+
let attach_commands = session_name
|
|
2068
|
+
.as_ref()
|
|
2069
|
+
.map(|session| {
|
|
2070
|
+
let windows = quick_start_attach_window_names(&state);
|
|
2071
|
+
crate::tmux_backend::attach_commands_for_windows(
|
|
2072
|
+
&workspace,
|
|
2073
|
+
session,
|
|
2074
|
+
windows.iter().map(String::as_str),
|
|
2075
|
+
)
|
|
2076
|
+
})
|
|
2077
|
+
.unwrap_or_default();
|
|
2078
|
+
let mut next_actions = vec![
|
|
2079
|
+
"run restart to resume the existing team or pass --fresh to replace it"
|
|
2080
|
+
.to_string(),
|
|
2081
|
+
];
|
|
2082
|
+
if session_name.is_some() {
|
|
2083
|
+
if crate::tmux_backend::socket_probe_missing_for_workspace(&workspace) {
|
|
2084
|
+
next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(
|
|
2085
|
+
&workspace,
|
|
2086
|
+
));
|
|
2087
|
+
}
|
|
2088
|
+
next_actions.extend(attach_commands.iter().cloned());
|
|
2089
|
+
}
|
|
1923
2090
|
return Ok(QuickStartReport::ExistingRuntime {
|
|
1924
2091
|
team: requested_team.clone(),
|
|
1925
|
-
session_name
|
|
1926
|
-
.get("session_name")
|
|
1927
|
-
.and_then(serde_json::Value::as_str)
|
|
1928
|
-
.filter(|s| !s.is_empty())
|
|
1929
|
-
.map(SessionName::new),
|
|
2092
|
+
session_name,
|
|
1930
2093
|
state_path: Some(state_path),
|
|
1931
|
-
next_actions
|
|
1932
|
-
|
|
1933
|
-
.to_string(),
|
|
1934
|
-
],
|
|
2094
|
+
next_actions,
|
|
2095
|
+
attach_commands,
|
|
1935
2096
|
});
|
|
1936
2097
|
}
|
|
1937
2098
|
}
|
|
@@ -2008,6 +2169,9 @@ pub fn quick_start_with_transport_in_workspace(
|
|
|
2008
2169
|
let mut next_actions = vec![format!(
|
|
2009
2170
|
"team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
|
|
2010
2171
|
)];
|
|
2172
|
+
if crate::tmux_backend::socket_probe_missing_for_workspace(&workspace) {
|
|
2173
|
+
next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(&workspace));
|
|
2174
|
+
}
|
|
2011
2175
|
next_actions.extend(attach_commands.iter().cloned());
|
|
2012
2176
|
let display_backend = state
|
|
2013
2177
|
.get("display_backend")
|
|
@@ -2024,6 +2188,29 @@ pub fn quick_start_with_transport_in_workspace(
|
|
|
2024
2188
|
})
|
|
2025
2189
|
}
|
|
2026
2190
|
|
|
2191
|
+
fn quick_start_attach_window_names(state: &serde_json::Value) -> Vec<String> {
|
|
2192
|
+
let mut windows = state
|
|
2193
|
+
.get("agents")
|
|
2194
|
+
.and_then(serde_json::Value::as_object)
|
|
2195
|
+
.map(|agents| {
|
|
2196
|
+
agents
|
|
2197
|
+
.iter()
|
|
2198
|
+
.filter_map(|(agent_id, agent)| {
|
|
2199
|
+
agent
|
|
2200
|
+
.get("window")
|
|
2201
|
+
.and_then(serde_json::Value::as_str)
|
|
2202
|
+
.filter(|window| !window.is_empty())
|
|
2203
|
+
.map(str::to_string)
|
|
2204
|
+
.or_else(|| Some(agent_id.clone()))
|
|
2205
|
+
})
|
|
2206
|
+
.collect::<Vec<_>>()
|
|
2207
|
+
})
|
|
2208
|
+
.unwrap_or_default();
|
|
2209
|
+
windows.sort();
|
|
2210
|
+
windows.dedup();
|
|
2211
|
+
windows
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2027
2214
|
/// BUG-7 helper: derive a [`QuickStartReadiness`] verdict from the just-written
|
|
2028
2215
|
/// runtime state. Reads `agents[*].status`; any non-`running` agent flips the
|
|
2029
2216
|
/// verdict to `Degraded { unhealthy_agents }` (sorted, deduped); otherwise
|
|
@@ -83,22 +83,28 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
83
83
|
workspace.join("team.spec.yaml").display()
|
|
84
84
|
)));
|
|
85
85
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
{
|
|
91
|
-
return Err(LifecycleError::TeamSelect(format!(
|
|
92
|
-
"missing spec for restart: {}",
|
|
93
|
-
workspace.join("team.spec.yaml").display()
|
|
94
|
-
)));
|
|
95
|
-
}
|
|
86
|
+
// RED-2(P0)修:存在性门下移到 resolve 之后,用 selected.spec_path(读序 B:runtime 优先、
|
|
87
|
+
// legacy 用户目录回落)判,而非 resolve 前自拼 workspace/team.spec.yaml(spec-demote 后 spec
|
|
88
|
+
// 不在用户目录 → 旧门误报缺)。resolve_active_team(RequireSpec) 内部已按 spec_path 校验存在性,
|
|
89
|
+
// 故直接交给它;失败信息(含真实 expected runtime spec 路径)即正确的 N38。
|
|
96
90
|
let selected = crate::state::selector::resolve_active_team(
|
|
97
91
|
workspace,
|
|
98
92
|
team,
|
|
99
93
|
crate::state::selector::SelectorMode::RequireSpec,
|
|
100
94
|
)
|
|
101
95
|
.map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
|
|
96
|
+
// 显式存在性门(下移后):selected.spec_path 经读序 B 已定位 runtime/legacy spec。
|
|
97
|
+
// 缺(空目录 restart 等)→ 报真实期望路径,不误导去用户目录找。
|
|
98
|
+
if !selected.spec_path.as_ref().is_some_and(|p| p.exists()) {
|
|
99
|
+
let expected = selected
|
|
100
|
+
.spec_path
|
|
101
|
+
.clone()
|
|
102
|
+
.unwrap_or_else(|| crate::model::paths::runtime_spec_path(&selected.run_workspace, &selected.team_key));
|
|
103
|
+
return Err(LifecycleError::TeamSelect(format!(
|
|
104
|
+
"missing spec for restart: {} (run `team-agent quick-start <teamdir>` first, or restore the team's role docs)",
|
|
105
|
+
expected.display()
|
|
106
|
+
)));
|
|
107
|
+
}
|
|
102
108
|
let mut state = selected.state;
|
|
103
109
|
crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, None)?;
|
|
104
110
|
// E5 task#3 / RC-A6a + E4(leader 裁定:每次 restart 都从角色定义重建 runtime spec,覆盖):
|