@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
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!({
|
|
@@ -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",
|
|
@@ -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(),
|
|
@@ -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() {
|
|
@@ -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 {
|
|
@@ -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).
|