@team-agent/installer 0.3.6 → 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/diagnose.rs +9 -0
- package/crates/team-agent/src/cli/emit.rs +175 -0
- package/crates/team-agent/src/cli/mod.rs +455 -63
- package/crates/team-agent/src/cli/status_port.rs +62 -0
- package/crates/team-agent/src/cli/tests/base.rs +9 -4
- package/crates/team-agent/src/cli/tests/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 +10 -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/cli/types.rs +3 -2
- package/crates/team-agent/src/compiler.rs +73 -50
- package/crates/team-agent/src/coordinator/tick.rs +108 -20
- package/crates/team-agent/src/db/migration.rs +17 -1
- package/crates/team-agent/src/leader/owner_bind.rs +59 -20
- package/crates/team-agent/src/lifecycle/launch.rs +378 -56
- package/crates/team-agent/src/lifecycle/restart/common.rs +4 -9
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +91 -12
- package/crates/team-agent/src/lifecycle/restart/selection.rs +6 -4
- package/crates/team-agent/src/lifecycle/tests/core.rs +238 -3
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +257 -7
- package/crates/team-agent/src/lifecycle/types.rs +2 -0
- package/crates/team-agent/src/mcp_server/normalize.rs +29 -7
- package/crates/team-agent/src/mcp_server/tests/golden.rs +7 -5
- package/crates/team-agent/src/mcp_server/tests/normalize.rs +5 -2
- package/crates/team-agent/src/mcp_server/tools.rs +25 -1
- package/crates/team-agent/src/mcp_server/wire.rs +11 -1
- package/crates/team-agent/src/model/paths.rs +7 -0
- package/crates/team-agent/src/model/spec.rs +23 -1
- package/crates/team-agent/src/packaging/install.rs +42 -4
- package/crates/team-agent/src/packaging/tests.rs +91 -14
- package/crates/team-agent/src/packaging/types.rs +13 -1
- package/crates/team-agent/src/provider/adapter.rs +381 -15
- package/crates/team-agent/src/state/identity.rs +29 -0
- package/crates/team-agent/src/state/selector.rs +48 -14
- package/crates/team-agent/src/tmux_backend/tests.rs +44 -0
- package/crates/team-agent/src/tmux_backend.rs +104 -9
- 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/skills/team-agent/SKILL.md +82 -5
|
@@ -29,6 +29,15 @@ use rusqlite::params;
|
|
|
29
29
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
30
30
|
let conn = crate::db::schema::open_db(store.db_path())
|
|
31
31
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
32
|
+
// B-5 / 036b N38 explicable — status 出口 runtime 块:把 coordinator_health
|
|
33
|
+
// (现状)+ undelivered backlog count 一起暴露;coordinator not running ∧
|
|
34
|
+
// backlog>0 才挂 down-hint(anti-nag)。auto-recovery 不做(user 已裁)。
|
|
35
|
+
let coordinator_running = coordinator_status_running(&health);
|
|
36
|
+
let undelivered_backlog = count_undelivered_backlog(&conn, owner_team_id)?;
|
|
37
|
+
let runtime_block = build_runtime_status_block(
|
|
38
|
+
coordinator_running,
|
|
39
|
+
undelivered_backlog,
|
|
40
|
+
);
|
|
32
41
|
let agents = enrich_agents(state.get("agents"));
|
|
33
42
|
let tasks = state
|
|
34
43
|
.get("tasks")
|
|
@@ -68,6 +77,7 @@ use rusqlite::params;
|
|
|
68
77
|
"latest_results": latest_result_summaries(&store, owner_team_id)?,
|
|
69
78
|
"readiness": readiness,
|
|
70
79
|
"coordinator": coordinator_health_value(health),
|
|
80
|
+
"runtime": runtime_block,
|
|
71
81
|
"last_events": Value::Array(
|
|
72
82
|
crate::event_log::EventLog::new(workspace)
|
|
73
83
|
.tail(10)
|
|
@@ -748,6 +758,58 @@ use rusqlite::params;
|
|
|
748
758
|
}
|
|
749
759
|
}
|
|
750
760
|
|
|
761
|
+
/// B-5 / 036b N38 — status 出口的 runtime 块:把 coordinator_health 与
|
|
762
|
+
/// undelivered backlog 合体暴露。down-hint 只在【coordinator 不在跑 ∧ 有 backlog】
|
|
763
|
+
/// 两条件同时满足才挂(anti-nag);健康状态下不挂提示。auto-recovery 不做。
|
|
764
|
+
fn build_runtime_status_block(coordinator_running: bool, undelivered: i64) -> Value {
|
|
765
|
+
let mut runtime = serde_json::Map::new();
|
|
766
|
+
runtime.insert(
|
|
767
|
+
"coordinator".to_string(),
|
|
768
|
+
json!({"ok": coordinator_running}),
|
|
769
|
+
);
|
|
770
|
+
runtime.insert("undelivered".to_string(), json!(undelivered));
|
|
771
|
+
if !coordinator_running && undelivered > 0 {
|
|
772
|
+
runtime.insert(
|
|
773
|
+
"hint".to_string(),
|
|
774
|
+
json!(format!(
|
|
775
|
+
"coordinator not running with {undelivered} undelivered — run team-agent restart"
|
|
776
|
+
)),
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
Value::Object(runtime)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/// Whether the coordinator HealthReport reflects a running tick loop. Used by the
|
|
783
|
+
/// runtime block + the hint gate.
|
|
784
|
+
fn coordinator_status_running(health: &crate::coordinator::HealthReport) -> bool {
|
|
785
|
+
matches!(health.status, crate::coordinator::CoordinatorHealthStatus::Running)
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/// Count of messages currently sitting in delivery-able backlog
|
|
789
|
+
/// (accepted/pending/queued forms — not delivered / not failed / not refused).
|
|
790
|
+
/// owner_team_id scope honored when present.
|
|
791
|
+
fn count_undelivered_backlog(
|
|
792
|
+
conn: &rusqlite::Connection,
|
|
793
|
+
owner_team_id: Option<&str>,
|
|
794
|
+
) -> Result<i64, CliError> {
|
|
795
|
+
// Backlog statuses chosen to mirror what `deliver_pending` would pick up.
|
|
796
|
+
let sql = match owner_team_id {
|
|
797
|
+
Some(_) => "select count(*) from messages
|
|
798
|
+
where owner_team_id = ?1 and status in ('accepted','pending','queued','queued_until_trust')",
|
|
799
|
+
None => "select count(*) from messages
|
|
800
|
+
where status in ('accepted','pending','queued','queued_until_trust')",
|
|
801
|
+
};
|
|
802
|
+
let count: i64 = match owner_team_id {
|
|
803
|
+
Some(team) => conn
|
|
804
|
+
.query_row(sql, params![team], |row| row.get(0))
|
|
805
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
806
|
+
None => conn
|
|
807
|
+
.query_row(sql, [], |row| row.get(0))
|
|
808
|
+
.map_err(|e| CliError::Runtime(e.to_string()))?,
|
|
809
|
+
};
|
|
810
|
+
Ok(count)
|
|
811
|
+
}
|
|
812
|
+
|
|
751
813
|
fn coordinator_health_value(health: crate::coordinator::HealthReport) -> Value {
|
|
752
814
|
json!({
|
|
753
815
|
"ok": health.ok,
|
|
@@ -400,15 +400,20 @@ latest result: none";
|
|
|
400
400
|
let payload = err.to_payload(Path::new("/tmp/cli-error-123.log"), "quick-start");
|
|
401
401
|
assert_eq!(payload.reason.as_deref(), Some("tmux_session_name_conflict"));
|
|
402
402
|
assert_eq!(payload.session_name.as_deref(), Some("my-team"));
|
|
403
|
+
// E8 (N38): quick-start 撞已有 runtime 引导到 restart(resume),明确 --fresh 会丢上下文。
|
|
403
404
|
assert_eq!(
|
|
404
405
|
payload.action,
|
|
405
|
-
"tmux session `my-team` already exists. It may be
|
|
406
|
-
|
|
407
|
-
change `name:` in TEAM.md and run quick-start again.
|
|
406
|
+
"tmux session `my-team` already exists. It may be your own existing team. \
|
|
407
|
+
To resume it use `team-agent restart` (NOT --fresh, which discards context). \
|
|
408
|
+
Only if you want a separate team, change `name:` in TEAM.md and run quick-start again. \
|
|
409
|
+
Never terminate existing tmux sessions from quick-start."
|
|
408
410
|
);
|
|
409
411
|
assert_eq!(
|
|
410
412
|
payload.next_actions,
|
|
411
|
-
Some(vec![
|
|
413
|
+
Some(vec![
|
|
414
|
+
"If this is your existing team, resume it with `team-agent restart`.".to_string(),
|
|
415
|
+
"If you want a separate team, change `name:` in TEAM.md and run `team-agent quick-start` again.".to_string(),
|
|
416
|
+
])
|
|
412
417
|
);
|
|
413
418
|
}
|
|
414
419
|
|
|
@@ -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).
|
|
@@ -209,10 +209,18 @@ fn current_uid() -> Option<String> {
|
|
|
209
209
|
json: true,
|
|
210
210
|
};
|
|
211
211
|
let _ = cmd_quick_start(&args); // real quick_start compiles the spec before any coordinator/launch step
|
|
212
|
+
// E5: spec compiled to .team/runtime/<team_key>/, NOT the user team dir.
|
|
213
|
+
let team_key = team.file_name().unwrap().to_string_lossy().to_string();
|
|
214
|
+
let workspace = crate::model::paths::team_workspace(&team).unwrap();
|
|
215
|
+
let runtime_spec = crate::model::paths::runtime_spec_path(&workspace, &team_key);
|
|
212
216
|
assert!(
|
|
213
|
-
|
|
217
|
+
runtime_spec.exists(),
|
|
214
218
|
"cmd_quick_start must delegate to crate::lifecycle::quick_start, which compiles team.spec.yaml \
|
|
215
|
-
under
|
|
219
|
+
under .team/runtime/<team_key>/; the placeholder never writes it"
|
|
220
|
+
);
|
|
221
|
+
assert!(
|
|
222
|
+
!team.join("team.spec.yaml").exists(),
|
|
223
|
+
"E5: quick_start must NOT write spec into the user team dir"
|
|
216
224
|
);
|
|
217
225
|
}
|
|
218
226
|
|
|
@@ -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
|
+
}
|
|
@@ -86,10 +86,11 @@ impl CliError {
|
|
|
86
86
|
payload.session_name = Some(session.clone());
|
|
87
87
|
if command == "quick-start" {
|
|
88
88
|
payload.action = format!(
|
|
89
|
-
"tmux session `{session}` already exists. It may be
|
|
89
|
+
"tmux session `{session}` already exists. It may be your own existing team. To resume it use `team-agent restart` (NOT --fresh, which discards context). Only if you want a separate team, change `name:` in TEAM.md and run quick-start again. Never terminate existing tmux sessions from quick-start."
|
|
90
90
|
);
|
|
91
91
|
payload.next_actions = Some(vec![
|
|
92
|
-
"
|
|
92
|
+
"If this is your existing team, resume it with `team-agent restart`.".to_string(),
|
|
93
|
+
"If you want a separate team, change `name:` in TEAM.md and run `team-agent quick-start` again.".to_string(),
|
|
93
94
|
]);
|
|
94
95
|
} else {
|
|
95
96
|
payload.action = format!(
|
|
@@ -141,56 +141,9 @@ pub fn compile_team(team_dir: &Path) -> Result<Value, ModelError> {
|
|
|
141
141
|
let mut agents = Vec::new();
|
|
142
142
|
let mut agent_ids = Vec::new();
|
|
143
143
|
for path in role_paths {
|
|
144
|
-
let
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
let provider = required_string(&meta, &path, "provider")?;
|
|
148
|
-
let model = resolve_model(&meta, &team_meta, &provider);
|
|
149
|
-
let auth_mode = string_field(&meta, "auth_mode")
|
|
150
|
-
.or_else(|| string_field(&team_meta, "default_auth_mode"))
|
|
151
|
-
.unwrap_or_else(|| "subscription".to_string());
|
|
152
|
-
if auth_mode != "subscription" && meta.get("profile").is_none() {
|
|
153
|
-
return Err(ModelError::Validation(format!(
|
|
154
|
-
"{}: profile is required when auth_mode is '{auth_mode}'",
|
|
155
|
-
path.display(),
|
|
156
|
-
)));
|
|
157
|
-
}
|
|
158
|
-
let tools = required_tools(&meta, &path)?;
|
|
159
|
-
let prompt_inline = non_empty_trimmed(&body).unwrap_or_else(|| role.clone());
|
|
160
|
-
agent_ids.push(id.clone());
|
|
161
|
-
let mut agent_items = vec![
|
|
162
|
-
("id", Value::Str(id.clone())),
|
|
163
|
-
("role", Value::Str(role.clone())),
|
|
164
|
-
("provider", Value::Str(provider)),
|
|
165
|
-
("model", model),
|
|
166
|
-
("auth_mode", Value::Str(auth_mode)),
|
|
167
|
-
("working_directory", Value::Str(workspace_s.clone())),
|
|
168
|
-
(
|
|
169
|
-
"system_prompt",
|
|
170
|
-
map(vec![
|
|
171
|
-
("inline", Value::Str(prompt_inline)),
|
|
172
|
-
("file", Value::Null),
|
|
173
|
-
]),
|
|
174
|
-
),
|
|
175
|
-
("tools", list_str(tools)),
|
|
176
|
-
("permission_mode", Value::Str("restricted".to_string())),
|
|
177
|
-
("preferred_for", list_str(vec![id, role])),
|
|
178
|
-
("avoid_for", Value::List(Vec::new())),
|
|
179
|
-
(
|
|
180
|
-
"output_contract",
|
|
181
|
-
map(vec![
|
|
182
|
-
("format", Value::Str("result_envelope_v1".to_string())),
|
|
183
|
-
(
|
|
184
|
-
"required_fields",
|
|
185
|
-
list_str(vec!["task_id", "status", "summary", "artifacts"]),
|
|
186
|
-
),
|
|
187
|
-
]),
|
|
188
|
-
),
|
|
189
|
-
];
|
|
190
|
-
if let Some(profile) = string_field(&meta, "profile") {
|
|
191
|
-
agent_items.push(("profile", Value::Str(profile)));
|
|
192
|
-
}
|
|
193
|
-
agents.push(map(agent_items));
|
|
144
|
+
let compiled = compile_role_agent(&path, &team_meta, &workspace_s)?;
|
|
145
|
+
agent_ids.push(compiled.id);
|
|
146
|
+
agents.push(compiled.agent);
|
|
194
147
|
}
|
|
195
148
|
|
|
196
149
|
let default_assignee = agent_ids.first().cloned().unwrap_or_default();
|
|
@@ -327,6 +280,76 @@ pub fn compile_team(team_dir: &Path) -> Result<Value, ModelError> {
|
|
|
327
280
|
Ok(spec)
|
|
328
281
|
}
|
|
329
282
|
|
|
283
|
+
/// 单个角色文档 → 编译后的 agent spec 条目(从 [`compile_team`] 的 per-role 循环抽出)。
|
|
284
|
+
/// E5 Bug1:add-agent 复用它**就地读** role 文件编译,不再 copy 进平台目录。
|
|
285
|
+
pub struct CompiledRole {
|
|
286
|
+
pub id: String,
|
|
287
|
+
pub role: String,
|
|
288
|
+
pub agent: Value,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/// 把一份 role 文档编译成 agent spec 条目。`team_meta` 供 model/auth_mode 继承;
|
|
292
|
+
/// `workspace_s` 是 working_directory。**纯读 `role_path`,无任何文件落地。**
|
|
293
|
+
pub fn compile_role_agent(
|
|
294
|
+
role_path: &Path,
|
|
295
|
+
team_meta: &Value,
|
|
296
|
+
workspace_s: &str,
|
|
297
|
+
) -> Result<CompiledRole, ModelError> {
|
|
298
|
+
let (meta, body) = read_front_matter(role_path)?;
|
|
299
|
+
let id = required_string(&meta, role_path, "name")?;
|
|
300
|
+
let role = required_string(&meta, role_path, "role")?;
|
|
301
|
+
let provider = required_string(&meta, role_path, "provider")?;
|
|
302
|
+
let model = resolve_model(&meta, team_meta, &provider);
|
|
303
|
+
let auth_mode = string_field(&meta, "auth_mode")
|
|
304
|
+
.or_else(|| string_field(team_meta, "default_auth_mode"))
|
|
305
|
+
.unwrap_or_else(|| "subscription".to_string());
|
|
306
|
+
if auth_mode != "subscription" && meta.get("profile").is_none() {
|
|
307
|
+
return Err(ModelError::Validation(format!(
|
|
308
|
+
"{}: profile is required when auth_mode is '{auth_mode}'",
|
|
309
|
+
role_path.display(),
|
|
310
|
+
)));
|
|
311
|
+
}
|
|
312
|
+
let tools = required_tools(&meta, role_path)?;
|
|
313
|
+
let prompt_inline = non_empty_trimmed(&body).unwrap_or_else(|| role.clone());
|
|
314
|
+
let mut agent_items = vec![
|
|
315
|
+
("id", Value::Str(id.clone())),
|
|
316
|
+
("role", Value::Str(role.clone())),
|
|
317
|
+
("provider", Value::Str(provider)),
|
|
318
|
+
("model", model),
|
|
319
|
+
("auth_mode", Value::Str(auth_mode)),
|
|
320
|
+
("working_directory", Value::Str(workspace_s.to_string())),
|
|
321
|
+
(
|
|
322
|
+
"system_prompt",
|
|
323
|
+
map(vec![
|
|
324
|
+
("inline", Value::Str(prompt_inline)),
|
|
325
|
+
("file", Value::Null),
|
|
326
|
+
]),
|
|
327
|
+
),
|
|
328
|
+
("tools", list_str(tools)),
|
|
329
|
+
("permission_mode", Value::Str("restricted".to_string())),
|
|
330
|
+
("preferred_for", list_str(vec![id.clone(), role.clone()])),
|
|
331
|
+
("avoid_for", Value::List(Vec::new())),
|
|
332
|
+
(
|
|
333
|
+
"output_contract",
|
|
334
|
+
map(vec![
|
|
335
|
+
("format", Value::Str("result_envelope_v1".to_string())),
|
|
336
|
+
(
|
|
337
|
+
"required_fields",
|
|
338
|
+
list_str(vec!["task_id", "status", "summary", "artifacts"]),
|
|
339
|
+
),
|
|
340
|
+
]),
|
|
341
|
+
),
|
|
342
|
+
];
|
|
343
|
+
if let Some(profile) = string_field(&meta, "profile") {
|
|
344
|
+
agent_items.push(("profile", Value::Str(profile)));
|
|
345
|
+
}
|
|
346
|
+
Ok(CompiledRole {
|
|
347
|
+
id,
|
|
348
|
+
role,
|
|
349
|
+
agent: map(agent_items),
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
|
|
330
353
|
fn map(items: Vec<(&str, Value)>) -> Value {
|
|
331
354
|
Value::Map(items.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
|
|
332
355
|
}
|