@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
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -133,13 +133,33 @@ pub fn cmd_quick_start(args: &QuickStartArgs) -> Result<CmdResult, CliError> {
|
|
|
133
133
|
}
|
|
134
134
|
Ok(result)
|
|
135
135
|
} else {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
136
|
+
// E13:happy 人类路径必须带 attach_commands(json 路径 cli/mod.rs:1775 已有)。
|
|
137
|
+
Ok(CmdResult::human(&quickstart_human(&value)))
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// E13:quick-start "team 起了" 人类输出 = summary + attach 块。所有成功出口共用(别每分支手拷)。
|
|
142
|
+
/// attach_commands 缺/空 → 只 summary(向后兼容)。
|
|
143
|
+
fn quickstart_human(value: &Value) -> String {
|
|
144
|
+
let summary = value
|
|
145
|
+
.get("summary")
|
|
146
|
+
.and_then(Value::as_str)
|
|
147
|
+
.unwrap_or("quick-start complete");
|
|
148
|
+
let attach: Vec<&str> = value
|
|
149
|
+
.get("attach_commands")
|
|
150
|
+
.and_then(Value::as_array)
|
|
151
|
+
.map(|items| items.iter().filter_map(Value::as_str).collect())
|
|
152
|
+
.unwrap_or_default();
|
|
153
|
+
if attach.is_empty() {
|
|
154
|
+
return summary.to_string();
|
|
155
|
+
}
|
|
156
|
+
let mut out = String::from(summary);
|
|
157
|
+
out.push_str("\n\nattach:");
|
|
158
|
+
for cmd in attach {
|
|
159
|
+
out.push_str("\n ");
|
|
160
|
+
out.push_str(cmd);
|
|
142
161
|
}
|
|
162
|
+
out
|
|
143
163
|
}
|
|
144
164
|
|
|
145
165
|
/// `cmd_compile`(`commands.py:42`)。
|
|
@@ -1522,9 +1542,34 @@ pub fn cmd_doctor(args: &DoctorArgs) -> Result<CmdResult, CliError> {
|
|
|
1522
1542
|
mod tests {
|
|
1523
1543
|
#![allow(clippy::unwrap_used)]
|
|
1524
1544
|
|
|
1525
|
-
use super::agent_pane_id;
|
|
1545
|
+
use super::{agent_pane_id, quickstart_human};
|
|
1526
1546
|
use serde_json::json;
|
|
1527
1547
|
|
|
1548
|
+
// E13:happy 人类输出必须带 attach 块(此前 else 分支只打 summary 丢 attach_commands)。
|
|
1549
|
+
#[test]
|
|
1550
|
+
fn e13_quickstart_human_includes_attach_commands() {
|
|
1551
|
+
let value = json!({
|
|
1552
|
+
"summary": "team started",
|
|
1553
|
+
"attach_commands": [
|
|
1554
|
+
"tmux -S /tmp/ta-x attach -t team-y:w1",
|
|
1555
|
+
"tmux -S /tmp/ta-x attach -t team-y:w2",
|
|
1556
|
+
],
|
|
1557
|
+
});
|
|
1558
|
+
let out = quickstart_human(&value);
|
|
1559
|
+
assert!(out.contains("team started"), "must keep summary; got {out}");
|
|
1560
|
+
assert!(out.contains("attach:"), "must render attach block; got {out}");
|
|
1561
|
+
assert!(out.contains("team-y:w1") && out.contains("team-y:w2"), "must list each attach cmd; got {out}");
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
#[test]
|
|
1565
|
+
fn e13_quickstart_human_summary_only_when_no_attach() {
|
|
1566
|
+
let value = json!({"summary": "quick-start complete"});
|
|
1567
|
+
assert_eq!(quickstart_human(&value), "quick-start complete");
|
|
1568
|
+
// 空数组也只 summary。
|
|
1569
|
+
let value2 = json!({"summary": "s", "attach_commands": []});
|
|
1570
|
+
assert_eq!(quickstart_human(&value2), "s");
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1528
1573
|
#[test]
|
|
1529
1574
|
fn agent_pane_id_resolves_session_window_even_with_recorded_pane_id() {
|
|
1530
1575
|
let state = json!({
|
|
@@ -23,7 +23,7 @@ pub fn emit(output: &CmdOutput, as_json: bool) -> Option<String> {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/// `main(argv)`(`parser.py:84`):**CLI 唯一进程入口**。codex/claude passthrough 早返回 →
|
|
26
|
+
/// `main(argv)`(`parser.py:84`):**CLI 唯一进程入口**。codex/claude/copilot passthrough 早返回 →
|
|
27
27
|
/// 解析 argv 到 subcommand → 调对应 handler → 异常落盘 + 信封 + `ExitCode::Error` →
|
|
28
28
|
/// `consume_leader_inbox_summary` → `emit` → `result.ok is False ? Error : Ok`。
|
|
29
29
|
/// **行为入口**:契约可端到端跑 argv→(stdout, exit code)。
|
|
@@ -31,7 +31,7 @@ pub fn run(argv: &[String], cwd: &Path) -> ExitCode {
|
|
|
31
31
|
let Some(command) = argv.first().map(String::as_str) else {
|
|
32
32
|
return emit_missing_subcommand_usage();
|
|
33
33
|
};
|
|
34
|
-
if command == "codex" || command == "claude" {
|
|
34
|
+
if command == "codex" || command == "claude" || command == "copilot" {
|
|
35
35
|
return cmd_leader_passthrough(command, &argv[1..], cwd)
|
|
36
36
|
.map(emit_result)
|
|
37
37
|
.unwrap_or(ExitCode::Error);
|
|
@@ -108,6 +108,7 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
|
|
|
108
108
|
"watch" => cmd_watch(&watch_args(args, cwd)).map(emit_result),
|
|
109
109
|
"sessions" => cmd_sessions(&sessions_args(args, cwd)).map(emit_result),
|
|
110
110
|
"validate" => cmd_validate(&validate_args(args, cwd)).map(emit_result),
|
|
111
|
+
"install-skill" => cmd_install_skill(&install_skill_args(args)?).map(emit_result),
|
|
111
112
|
"profile" => cmd_profile(&profile_args(args, cwd)?).map(emit_result),
|
|
112
113
|
"validate-result" if has_arg(args, "--result") => {
|
|
113
114
|
eprintln!("team-agent: error: unrecognized arguments: --result");
|
|
@@ -160,6 +161,7 @@ const DISPATCH_COMMANDS: &[&str] = &[
|
|
|
160
161
|
"watch",
|
|
161
162
|
"sessions",
|
|
162
163
|
"validate",
|
|
164
|
+
"install-skill",
|
|
163
165
|
"profile",
|
|
164
166
|
"validate-result",
|
|
165
167
|
"collect",
|
|
@@ -191,7 +193,7 @@ fn is_known_subcommand(command: &str) -> bool {
|
|
|
191
193
|
fn command_help(command: Option<&str>) -> String {
|
|
192
194
|
match command {
|
|
193
195
|
None => {
|
|
194
|
-
let mut commands = vec!["codex", "claude"];
|
|
196
|
+
let mut commands = vec!["codex", "claude", "copilot"];
|
|
195
197
|
commands.extend_from_slice(DISPATCH_COMMANDS);
|
|
196
198
|
commands.extend_from_slice(SPEC_ONLY_HELP_COMMANDS);
|
|
197
199
|
format!(
|
|
@@ -230,6 +232,7 @@ fn command_help(command: Option<&str>) -> String {
|
|
|
230
232
|
Some("watch") => "usage: team-agent watch [--workspace WORKSPACE] [--team TEAM]".to_string(),
|
|
231
233
|
Some("sessions") => "usage: team-agent sessions [--workspace WORKSPACE] [--json]".to_string(),
|
|
232
234
|
Some("validate") => "usage: team-agent validate [SPEC] [--json]".to_string(),
|
|
235
|
+
Some("install-skill") => "usage: team-agent install-skill (--source DIR | --uninstall) [--target codex|claude|copilot|all] [--dest DIR] [--dry-run] [--json]".to_string(),
|
|
233
236
|
Some("profile") => "usage: team-agent profile COMMAND NAME [--workspace WORKSPACE] [--team TEAM] [--auth-mode MODE] [--json]".to_string(),
|
|
234
237
|
Some("validate-result") => "usage: team-agent validate-result [ENVELOPE] [--file FILE|--result JSON] [--json]".to_string(),
|
|
235
238
|
Some("collect") => "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]".to_string(),
|
|
@@ -258,7 +261,7 @@ fn emit_unknown_subcommand_usage(command: &str) -> ExitCode {
|
|
|
258
261
|
|
|
259
262
|
/// 在已知子命令里找与 `input` 最接近的一个(Levenshtein ≤ 阈值)。无足够接近者 → None。
|
|
260
263
|
fn nearest_subcommand(input: &str) -> Option<&'static str> {
|
|
261
|
-
let mut candidates: Vec<&'static str> = vec!["codex", "claude"];
|
|
264
|
+
let mut candidates: Vec<&'static str> = vec!["codex", "claude", "copilot"];
|
|
262
265
|
candidates.extend_from_slice(DISPATCH_COMMANDS);
|
|
263
266
|
candidates.extend_from_slice(SPEC_ONLY_HELP_COMMANDS);
|
|
264
267
|
// 阈值随长度放宽,但短词收紧,避免 'x' 误配任何东西。
|
|
@@ -298,6 +301,115 @@ fn emit_usage_error(message: &str) {
|
|
|
298
301
|
}
|
|
299
302
|
|
|
300
303
|
/// `cmd_validate` delegates to runtime validate_file.
|
|
304
|
+
/// `install-skill` 参数(RED-1 根治:把 skill 安装单源收敛到二进制,install.mjs 调它)。
|
|
305
|
+
struct InstallSkillArgs {
|
|
306
|
+
target: crate::packaging::SkillTarget,
|
|
307
|
+
dest: Option<PathBuf>,
|
|
308
|
+
dry_run: bool,
|
|
309
|
+
/// `--uninstall`:删 target 的 skill 目标目录(单源,走同一 SkillTarget 表),不需 --source。
|
|
310
|
+
uninstall: bool,
|
|
311
|
+
source: Option<PathBuf>,
|
|
312
|
+
json: bool,
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
fn install_skill_args(args: &[String]) -> Result<InstallSkillArgs, CliError> {
|
|
316
|
+
let parsed = parse_args(args);
|
|
317
|
+
// `--target` 复用 parse_args.targets(codex|claude|copilot|all,默认 all)。
|
|
318
|
+
let target = match parsed.targets.as_deref() {
|
|
319
|
+
None | Some("all") => crate::packaging::SkillTarget::All,
|
|
320
|
+
Some("codex") => crate::packaging::SkillTarget::Codex,
|
|
321
|
+
Some("claude") => crate::packaging::SkillTarget::Claude,
|
|
322
|
+
Some("copilot") => crate::packaging::SkillTarget::Copilot,
|
|
323
|
+
Some(other) => {
|
|
324
|
+
return Err(CliError::Usage(format!(
|
|
325
|
+
"invalid --target: {other} (choose from codex, claude, copilot, all)"
|
|
326
|
+
)))
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
let uninstall = args.iter().any(|a| a == "--uninstall");
|
|
330
|
+
// `--source <dir>` 安装时必需(npm 包的 skills/team-agent;运行期无 CARGO_MANIFEST_DIR);
|
|
331
|
+
// 卸载不需要。
|
|
332
|
+
let source = flag_value(args, "--source").map(PathBuf::from);
|
|
333
|
+
if !uninstall && source.is_none() {
|
|
334
|
+
return Err(CliError::Usage("missing --source <skill dir>".to_string()));
|
|
335
|
+
}
|
|
336
|
+
let dest = flag_value(args, "--dest").map(PathBuf::from);
|
|
337
|
+
let dry_run = args.iter().any(|a| a == "--dry-run");
|
|
338
|
+
Ok(InstallSkillArgs {
|
|
339
|
+
target,
|
|
340
|
+
dest,
|
|
341
|
+
dry_run,
|
|
342
|
+
uninstall,
|
|
343
|
+
source,
|
|
344
|
+
json: parsed.json,
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/// 取 `--flag <value>` 的值(用于 install-skill 的 --source/--dest,parse_args 不覆盖的旗标)。
|
|
349
|
+
fn flag_value(args: &[String], flag: &str) -> Option<String> {
|
|
350
|
+
args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1).cloned())
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/// `team-agent install-skill`(RED-1 单源):repo `skills/team-agent` → `~/.codex|.claude|.copilot`。
|
|
354
|
+
/// install.mjs 删 JS 拷贝逻辑、改调本命令(`--target all --source <pkg>/skills/team-agent`)。
|
|
355
|
+
fn cmd_install_skill(args: &InstallSkillArgs) -> Result<CmdResult, CliError> {
|
|
356
|
+
// 卸载分支(单源:走同一 SkillTarget 表的 dest_dir;all → SINGLE_TARGETS 全集)。
|
|
357
|
+
if args.uninstall {
|
|
358
|
+
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
|
|
359
|
+
let targets: Vec<crate::packaging::SkillTarget> = match args.target {
|
|
360
|
+
crate::packaging::SkillTarget::All => {
|
|
361
|
+
crate::packaging::SkillTarget::SINGLE_TARGETS.to_vec()
|
|
362
|
+
}
|
|
363
|
+
t => vec![t],
|
|
364
|
+
};
|
|
365
|
+
let mut removed: Vec<serde_json::Value> = Vec::new();
|
|
366
|
+
for t in targets {
|
|
367
|
+
if let Some(dest) = t.dest_dir(&home) {
|
|
368
|
+
let existed = dest.0.exists();
|
|
369
|
+
if existed && !args.dry_run {
|
|
370
|
+
std::fs::remove_dir_all(&dest.0).map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
371
|
+
}
|
|
372
|
+
removed.push(serde_json::json!({
|
|
373
|
+
"target": t,
|
|
374
|
+
"dest": dest.0.to_string_lossy(),
|
|
375
|
+
"removed": existed,
|
|
376
|
+
"dry_run": args.dry_run,
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return Ok(CmdResult::from_json(
|
|
381
|
+
serde_json::json!({"ok": true, "uninstalled": removed}),
|
|
382
|
+
args.json,
|
|
383
|
+
));
|
|
384
|
+
}
|
|
385
|
+
let source = args
|
|
386
|
+
.source
|
|
387
|
+
.clone()
|
|
388
|
+
.ok_or_else(|| CliError::Usage("missing --source <skill dir>".to_string()))?;
|
|
389
|
+
let outcomes = crate::packaging::install::install_skill(&crate::packaging::SkillInstallOptions {
|
|
390
|
+
target: args.target,
|
|
391
|
+
dest: args.dest.clone(),
|
|
392
|
+
dry_run: args.dry_run,
|
|
393
|
+
source,
|
|
394
|
+
})
|
|
395
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
396
|
+
let installed: Vec<serde_json::Value> = outcomes
|
|
397
|
+
.iter()
|
|
398
|
+
.map(|o| {
|
|
399
|
+
serde_json::json!({
|
|
400
|
+
"target": o.target,
|
|
401
|
+
"dest": o.dest.0.to_string_lossy(),
|
|
402
|
+
"dry_run": o.dry_run,
|
|
403
|
+
"removed_stale": o.removed_stale.len(),
|
|
404
|
+
})
|
|
405
|
+
})
|
|
406
|
+
.collect();
|
|
407
|
+
Ok(CmdResult::from_json(
|
|
408
|
+
serde_json::json!({"ok": true, "installed": installed}),
|
|
409
|
+
args.json,
|
|
410
|
+
))
|
|
411
|
+
}
|
|
412
|
+
|
|
301
413
|
pub fn cmd_validate(args: &ValidateArgs) -> Result<CmdResult, CliError> {
|
|
302
414
|
let spec = resolve_path(&args.spec);
|
|
303
415
|
let value = if spec.is_dir() {
|
|
@@ -1306,6 +1418,22 @@ mod tests {
|
|
|
1306
1418
|
"top-level --help is missing spec-only help command `{command}`"
|
|
1307
1419
|
);
|
|
1308
1420
|
}
|
|
1421
|
+
assert!(
|
|
1422
|
+
top_help.contains("copilot"),
|
|
1423
|
+
"top-level leader passthrough help must list copilot"
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
#[test]
|
|
1428
|
+
fn copilot_is_listed_as_leader_passthrough_candidate() {
|
|
1429
|
+
assert!(command_help(None).contains("copilot"));
|
|
1430
|
+
assert_eq!(nearest_subcommand("copliot"), Some("copilot"));
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
#[test]
|
|
1434
|
+
fn copilot_help_dispatches_as_leader_passthrough() {
|
|
1435
|
+
let cwd = tmp_workspace();
|
|
1436
|
+
assert_eq!(run(&cli_argv(&["copilot", "--help"]), &cwd), ExitCode::Ok);
|
|
1309
1437
|
}
|
|
1310
1438
|
|
|
1311
1439
|
#[test]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//! cli · leader — `codex`/`claude` passthrough(`cmd_leader_passthrough` + `_provider_args` /
|
|
1
|
+
//! cli · leader — `codex`/`claude`/`copilot` passthrough(`cmd_leader_passthrough` + `_provider_args` /
|
|
2
2
|
//! `_leader_launcher_args`)+ leader fallback inbox 摘要(`consume_leader_inbox_summary` 及
|
|
3
3
|
//! `_leader_inbox_entries` / `_leader_inbox_summary` / `_leader_inbox_entry_title`)。
|
|
4
4
|
|
|
@@ -68,9 +68,9 @@ fn without_leader_json(values: &[String]) -> Vec<String> {
|
|
|
68
68
|
out
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/// `codex`/`claude` passthrough(`parser.py:86`/`_run_leader_passthrough`):leader 早返回,
|
|
71
|
+
/// `codex`/`claude`/`copilot` passthrough(`parser.py:86`/`_run_leader_passthrough`):leader 早返回,
|
|
72
72
|
/// **不**进 subparser。`-h`/`--help` 打 usage 直接返回 [`CmdResult::none`]。否则解析 attach
|
|
73
|
-
/// 旗标 + `lifecycle_port::start_leader`。`command` ∈ {codex, claude}。
|
|
73
|
+
/// 旗标 + `lifecycle_port::start_leader`。`command` ∈ {codex, claude, copilot}。
|
|
74
74
|
pub fn cmd_leader_passthrough(
|
|
75
75
|
command: &str,
|
|
76
76
|
provider_args: &[String],
|
|
@@ -82,15 +82,19 @@ pub fn cmd_leader_passthrough(
|
|
|
82
82
|
let as_json = leader_launcher_json(provider_args);
|
|
83
83
|
let launcher_args = without_leader_json(provider_args);
|
|
84
84
|
let attach = leader_launcher_args(&launcher_args)?;
|
|
85
|
-
let provider =
|
|
86
|
-
crate::model::enums::Provider::Codex
|
|
87
|
-
} else {
|
|
88
|
-
crate::model::enums::Provider::ClaudeCode
|
|
89
|
-
};
|
|
85
|
+
let provider = leader_passthrough_provider(command);
|
|
90
86
|
let value = lifecycle_port::start_leader(provider, &attach.provider_args, cwd, &attach)?;
|
|
91
87
|
Ok(CmdResult::from_json(value, as_json))
|
|
92
88
|
}
|
|
93
89
|
|
|
90
|
+
pub(crate) fn leader_passthrough_provider(command: &str) -> crate::model::enums::Provider {
|
|
91
|
+
match command {
|
|
92
|
+
"codex" => crate::model::enums::Provider::Codex,
|
|
93
|
+
"copilot" => crate::model::enums::Provider::Copilot,
|
|
94
|
+
_ => crate::model::enums::Provider::ClaudeCode,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
94
98
|
// =============================================================================
|
|
95
99
|
// leader fallback inbox 摘要(helpers.py `consume_leader_inbox_summary`)
|
|
96
100
|
// =============================================================================
|
|
@@ -168,6 +168,7 @@ pub mod lifecycle_port {
|
|
|
168
168
|
let run_ws = crate::model::paths::canonical_run_workspace(workspace)
|
|
169
169
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
170
170
|
let state = shutdown_state_for_team(&run_ws, team)?;
|
|
171
|
+
let state_for_kill = state.clone();
|
|
171
172
|
let transport = if let Some(endpoint) = legacy_worker_tmux_endpoint(&state) {
|
|
172
173
|
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
|
|
173
174
|
} else {
|
|
@@ -176,17 +177,33 @@ pub mod lifecycle_port {
|
|
|
176
177
|
let result =
|
|
177
178
|
shutdown_with_transport_and_state(workspace, keep_logs, team, &transport, Some(state));
|
|
178
179
|
if team.is_none() {
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
180
|
+
// E12 (P0): the leader terminal lives on this socket by design. A bare shutdown must
|
|
181
|
+
// NOT `kill-server` it away. spare = state-anchor sessions ∪ `team-agent-leader-*`
|
|
182
|
+
// prefix sessions (union; cr E12 ①). kill_server only when the socket is exclusively
|
|
183
|
+
// ours (no spare + no foreign session); shared socket → kill our sessions individually
|
|
184
|
+
// (cr E12 ②). All spare derivation comes from ONE snapshot (list_targets + the state
|
|
185
|
+
// already loaded) — no independent ps/tmux re-derivation (N39).
|
|
184
186
|
let transport_dyn: &dyn crate::transport::Transport = &transport;
|
|
187
|
+
let pane_targets = transport_dyn.list_targets().unwrap_or_default();
|
|
185
188
|
let sessions = socket_session_names(transport_dyn);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
let event_log = crate::event_log::EventLog::new(&run_ws);
|
|
190
|
+
let anchor_sessions =
|
|
191
|
+
anchor_sessions_from_state(&state_for_kill, &pane_targets, &event_log);
|
|
192
|
+
let decision = sessions_to_kill(&sessions, &anchor_sessions);
|
|
193
|
+
match decision {
|
|
194
|
+
KillDecision::KillServerExclusive => transport.kill_server(),
|
|
195
|
+
KillDecision::KillIndividually { to_kill, spared } => {
|
|
196
|
+
if !spared.is_empty() || to_kill.len() != sessions.len() {
|
|
197
|
+
// shared socket / leader spared → never whole-server teardown.
|
|
198
|
+
let _ = event_log.write(
|
|
199
|
+
"shutdown.kill_server_skipped_shared_socket",
|
|
200
|
+
json!({
|
|
201
|
+
"spared_sessions": spared.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
202
|
+
"killed_sessions": to_kill.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
for session in &to_kill {
|
|
190
207
|
let _ = transport_dyn.kill_session(session);
|
|
191
208
|
}
|
|
192
209
|
}
|
|
@@ -195,6 +212,29 @@ pub mod lifecycle_port {
|
|
|
195
212
|
result
|
|
196
213
|
}
|
|
197
214
|
|
|
215
|
+
/// E12 ①:从 state 锚 pane_id(leader_receiver/team_owner,top+teams)映射到其所在 session
|
|
216
|
+
/// (经同一帧 list_targets pane→session)。state 无任何锚 → 退命名判据 + spare_fallback event。
|
|
217
|
+
fn anchor_sessions_from_state(
|
|
218
|
+
state: &Value,
|
|
219
|
+
pane_targets: &[crate::transport::PaneInfo],
|
|
220
|
+
event_log: &crate::event_log::EventLog,
|
|
221
|
+
) -> std::collections::BTreeSet<String> {
|
|
222
|
+
let anchor_pane_ids = collect_state_leader_anchor_pane_ids(state);
|
|
223
|
+
if anchor_pane_ids.is_empty() {
|
|
224
|
+
// 无锚(state 损坏/未记)→ 退纯命名前缀判据(下游 sessions_to_kill 仍 spare 前缀)。
|
|
225
|
+
let _ = event_log.write(
|
|
226
|
+
"shutdown.spare_fallback_to_naming",
|
|
227
|
+
json!({"reason": "no leader_receiver/team_owner pane anchor in state"}),
|
|
228
|
+
);
|
|
229
|
+
return std::collections::BTreeSet::new();
|
|
230
|
+
}
|
|
231
|
+
pane_targets
|
|
232
|
+
.iter()
|
|
233
|
+
.filter(|pane| anchor_pane_ids.contains(pane.pane_id.as_str()))
|
|
234
|
+
.map(|pane| pane.session.as_str().to_string())
|
|
235
|
+
.collect()
|
|
236
|
+
}
|
|
237
|
+
|
|
198
238
|
fn socket_session_names(
|
|
199
239
|
transport: &dyn crate::transport::Transport,
|
|
200
240
|
) -> Vec<crate::transport::SessionName> {
|
|
@@ -208,26 +248,37 @@ pub mod lifecycle_port {
|
|
|
208
248
|
.collect()
|
|
209
249
|
}
|
|
210
250
|
|
|
211
|
-
///
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
251
|
+
/// E12 下沉纯函数:bare-shutdown socket 拆除决策。
|
|
252
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
253
|
+
pub(crate) enum KillDecision {
|
|
254
|
+
/// socket 独享(无 spare、无外来 session)→ 可整 server 拆除。
|
|
255
|
+
KillServerExclusive,
|
|
256
|
+
/// 有 spare(leader 锚/前缀)或非独享 → 逐 session kill,绝不 kill-server。
|
|
257
|
+
KillIndividually {
|
|
258
|
+
to_kill: Vec<crate::transport::SessionName>,
|
|
259
|
+
spared: Vec<crate::transport::SessionName>,
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/// E12 纯决策(单测下沉):spare = `anchor_sessions` ∪ `team-agent-leader-*` 前缀(并集,锚优先)。
|
|
264
|
+
/// 全部 session 都不 spare 且非空 → `KillServerExclusive`(独享 socket 兜底);否则逐 session
|
|
265
|
+
/// kill 非 spare 的(共享 socket / leader 在 → 绝不整 server 拆)。空 session 集 → 逐 kill(no-op)。
|
|
266
|
+
pub(crate) fn sessions_to_kill(
|
|
215
267
|
sessions: &[crate::transport::SessionName],
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
})
|
|
268
|
+
anchor_sessions: &std::collections::BTreeSet<String>,
|
|
269
|
+
) -> KillDecision {
|
|
270
|
+
let is_spared = |s: &crate::transport::SessionName| {
|
|
271
|
+
s.as_str().starts_with(crate::leader::LEADER_SESSION_PREFIX)
|
|
272
|
+
|| anchor_sessions.contains(s.as_str())
|
|
273
|
+
};
|
|
274
|
+
let spared: Vec<_> = sessions.iter().filter(|s| is_spared(s)).cloned().collect();
|
|
275
|
+
let to_kill: Vec<_> = sessions.iter().filter(|s| !is_spared(s)).cloned().collect();
|
|
276
|
+
// 独享 = 非空 + 无 spare(socket 上每个 session 都是要 kill 的我方 session)。
|
|
277
|
+
if spared.is_empty() && !sessions.is_empty() {
|
|
278
|
+
KillDecision::KillServerExclusive
|
|
279
|
+
} else {
|
|
280
|
+
KillDecision::KillIndividually { to_kill, spared }
|
|
281
|
+
}
|
|
231
282
|
}
|
|
232
283
|
|
|
233
284
|
pub fn shutdown_with_transport(
|
|
@@ -1782,6 +1833,7 @@ pub mod lifecycle_port {
|
|
|
1782
1833
|
session_name,
|
|
1783
1834
|
state_path,
|
|
1784
1835
|
next_actions,
|
|
1836
|
+
attach_commands,
|
|
1785
1837
|
} => json!({
|
|
1786
1838
|
"ok": false,
|
|
1787
1839
|
"summary": "existing runtime",
|
|
@@ -1789,20 +1841,61 @@ pub mod lifecycle_port {
|
|
|
1789
1841
|
"session_name": session_name.map(|s| s.as_str().to_string()),
|
|
1790
1842
|
"state_path": state_path.map(|p| p.to_string_lossy().to_string()),
|
|
1791
1843
|
"next_actions": next_actions,
|
|
1844
|
+
"attach_commands": attach_commands,
|
|
1792
1845
|
}),
|
|
1793
1846
|
crate::lifecycle::QuickStartReport::PreflightBlocked {
|
|
1794
1847
|
summary,
|
|
1795
1848
|
blockers,
|
|
1796
1849
|
next_actions,
|
|
1850
|
+
attach_commands,
|
|
1797
1851
|
} => json!({
|
|
1798
1852
|
"ok": false,
|
|
1799
1853
|
"summary": summary,
|
|
1800
1854
|
"blockers": blockers,
|
|
1801
1855
|
"next_actions": next_actions,
|
|
1856
|
+
"attach_commands": attach_commands,
|
|
1802
1857
|
}),
|
|
1803
1858
|
}
|
|
1804
1859
|
}
|
|
1805
1860
|
|
|
1861
|
+
#[cfg(test)]
|
|
1862
|
+
mod quick_start_value_tests {
|
|
1863
|
+
use super::*;
|
|
1864
|
+
|
|
1865
|
+
#[test]
|
|
1866
|
+
fn existing_runtime_json_includes_attach_commands() {
|
|
1867
|
+
let value = quick_start_value(crate::lifecycle::QuickStartReport::ExistingRuntime {
|
|
1868
|
+
team: Some("teamA".to_string()),
|
|
1869
|
+
session_name: Some(crate::transport::SessionName::new("team-teamA")),
|
|
1870
|
+
state_path: Some(PathBuf::from("/tmp/state.json")),
|
|
1871
|
+
next_actions: vec!["restart".to_string()],
|
|
1872
|
+
attach_commands: vec![
|
|
1873
|
+
"tmux -S /tmp/tmux-501/ta-test attach -t team-teamA:worker".to_string(),
|
|
1874
|
+
],
|
|
1875
|
+
});
|
|
1876
|
+
assert_eq!(
|
|
1877
|
+
value.pointer("/attach_commands/0").and_then(Value::as_str),
|
|
1878
|
+
Some("tmux -S /tmp/tmux-501/ta-test attach -t team-teamA:worker"),
|
|
1879
|
+
"B-2: ExistingRuntime JSON must preserve attach_commands instead of only next_actions; value={value}"
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
#[test]
|
|
1884
|
+
fn preflight_blocked_json_includes_empty_attach_commands() {
|
|
1885
|
+
let value = quick_start_value(crate::lifecycle::QuickStartReport::PreflightBlocked {
|
|
1886
|
+
summary: "blocked".to_string(),
|
|
1887
|
+
blockers: vec!["missing TEAM.md".to_string()],
|
|
1888
|
+
next_actions: vec!["fix preflight blockers".to_string()],
|
|
1889
|
+
attach_commands: Vec::new(),
|
|
1890
|
+
});
|
|
1891
|
+
assert_eq!(
|
|
1892
|
+
value.get("attach_commands").and_then(Value::as_array).map(Vec::len),
|
|
1893
|
+
Some(0),
|
|
1894
|
+
"B-2: PreflightBlocked JSON must include attach_commands: [] for schema parity with Ready/Restart; value={value}"
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1806
1899
|
fn restart_value(report: crate::lifecycle::RestartReport) -> Value {
|
|
1807
1900
|
match report {
|
|
1808
1901
|
crate::lifecycle::RestartReport::Restarted {
|
|
@@ -617,5 +617,19 @@ Truncated: more fallback entries available; run team-agent inbox leader";
|
|
|
617
617
|
assert_eq!(r.exit, ExitCode::Ok);
|
|
618
618
|
let r2 = cmd_leader_passthrough("claude", &["--help".into()], Path::new(".")).unwrap();
|
|
619
619
|
assert_eq!(r2.output, CmdOutput::None);
|
|
620
|
+
let r3 = cmd_leader_passthrough("copilot", &["--help".into()], Path::new(".")).unwrap();
|
|
621
|
+
assert_eq!(r3.output, CmdOutput::None);
|
|
620
622
|
}
|
|
621
623
|
|
|
624
|
+
#[test]
|
|
625
|
+
fn cmd_leader_passthrough_maps_copilot_provider() {
|
|
626
|
+
assert_eq!(leader_passthrough_provider("codex"), crate::model::enums::Provider::Codex);
|
|
627
|
+
assert_eq!(
|
|
628
|
+
leader_passthrough_provider("claude"),
|
|
629
|
+
crate::model::enums::Provider::ClaudeCode
|
|
630
|
+
);
|
|
631
|
+
assert_eq!(
|
|
632
|
+
leader_passthrough_provider("copilot"),
|
|
633
|
+
crate::model::enums::Provider::Copilot
|
|
634
|
+
);
|
|
635
|
+
}
|