@team-agent/installer 0.3.6 → 0.3.7

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.
Files changed (33) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/diagnose.rs +9 -0
  4. package/crates/team-agent/src/cli/emit.rs +63 -0
  5. package/crates/team-agent/src/cli/mod.rs +334 -35
  6. package/crates/team-agent/src/cli/status_port.rs +62 -0
  7. package/crates/team-agent/src/cli/tests/base.rs +9 -4
  8. package/crates/team-agent/src/cli/tests/run_delegation.rs +10 -2
  9. package/crates/team-agent/src/cli/types.rs +3 -2
  10. package/crates/team-agent/src/compiler.rs +73 -50
  11. package/crates/team-agent/src/coordinator/tick.rs +108 -20
  12. package/crates/team-agent/src/db/migration.rs +17 -1
  13. package/crates/team-agent/src/lifecycle/launch.rs +182 -47
  14. package/crates/team-agent/src/lifecycle/restart/common.rs +4 -9
  15. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +75 -2
  16. package/crates/team-agent/src/lifecycle/restart/selection.rs +6 -4
  17. package/crates/team-agent/src/lifecycle/tests/core.rs +46 -3
  18. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +221 -7
  19. package/crates/team-agent/src/mcp_server/normalize.rs +29 -7
  20. package/crates/team-agent/src/mcp_server/tests/golden.rs +7 -5
  21. package/crates/team-agent/src/mcp_server/tests/normalize.rs +5 -2
  22. package/crates/team-agent/src/mcp_server/tools.rs +25 -1
  23. package/crates/team-agent/src/mcp_server/wire.rs +11 -1
  24. package/crates/team-agent/src/model/paths.rs +7 -0
  25. package/crates/team-agent/src/model/spec.rs +23 -1
  26. package/crates/team-agent/src/packaging/install.rs +42 -4
  27. package/crates/team-agent/src/packaging/tests.rs +91 -14
  28. package/crates/team-agent/src/packaging/types.rs +13 -1
  29. package/crates/team-agent/src/provider/adapter.rs +204 -0
  30. package/crates/team-agent/src/state/selector.rs +48 -14
  31. package/crates/team-agent/src/tmux_backend.rs +14 -2
  32. package/package.json +4 -4
  33. package/skills/team-agent/SKILL.md +82 -5
@@ -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 an active team. \
406
- Do not terminate existing tmux sessions from quick-start; \
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!["Change `name:` in TEAM.md and run `team-agent quick-start` again.".to_string()])
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
 
@@ -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
- team.join("team.spec.yaml").exists(),
217
+ runtime_spec.exists(),
214
218
  "cmd_quick_start must delegate to crate::lifecycle::quick_start, which compiles team.spec.yaml \
215
- under the team dir; the placeholder never writes it"
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
 
@@ -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 an active team. Do not terminate existing tmux sessions from quick-start; change `name:` in TEAM.md and run quick-start again."
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
- "Change `name:` in TEAM.md and run `team-agent quick-start` again.".to_string(),
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 (meta, body) = read_front_matter(&path)?;
145
- let id = required_string(&meta, &path, "name")?;
146
- let role = required_string(&meta, &path, "role")?;
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
  }
@@ -228,8 +228,18 @@ impl Coordinator {
228
228
  );
229
229
  }
230
230
 
231
+ // B-4 / 036b N36 三路可用 — 监测步(runtime_prompts / sync_health /
232
+ // detect_abnormal_exits)失败必须降级+continue,**不能**用 `?` 中断 tick,
233
+ // 否则 deliver_pending(下行投递主干)够不到,消息卡 accepted。
234
+ // bug-084 哲学 + A-6 同族:每步独立 try,失败写 `coordinator.tick.<step>_failed`
235
+ // 事件后继续走下一步;tick 本身仍返 Ok。
231
236
  self.record_step("runtime_prompts");
232
- self.handle_runtime_approval_prompts(&mut state, &event_log)?;
237
+ if let Err(error) = self.handle_runtime_approval_prompts(&mut state, &event_log) {
238
+ let _ = event_log.write(
239
+ "coordinator.tick.runtime_prompts_failed",
240
+ serde_json::json!({"error": error.to_string()}),
241
+ );
242
+ }
233
243
 
234
244
  self.record_step("sync_health");
235
245
  // P5 (C-P5-1, N3): ONE pane snapshot per tick, shared by sync_health and the
@@ -237,32 +247,71 @@ impl Coordinator {
237
247
  // this tick; every tick re-reads).
238
248
  let pane_snapshot = self.transport.list_targets().unwrap_or_default();
239
249
  let captures_by_agent =
240
- self.sync_agent_health(&mut state, &store, &event_log, &pane_snapshot)?;
250
+ match self.sync_agent_health(&mut state, &store, &event_log, &pane_snapshot) {
251
+ Ok(captures) => captures,
252
+ Err(error) => {
253
+ let _ = event_log.write(
254
+ "coordinator.tick.sync_health_failed",
255
+ serde_json::json!({"error": error.to_string()}),
256
+ );
257
+ BTreeMap::new()
258
+ }
259
+ };
241
260
  // C-3-4 cr verdict — copilot 一期 classify→None(Unknown);为防 silent,
242
261
  // tick 每次发现 copilot agent(从 state.agents 直接扫,不依赖 captures —
243
262
  // 离线/未起 tmux 场景仍能写)就发 `provider.classify.unsupported` 事件
244
263
  // (字面 reason=`phase1_unknown_pending_sample`,含 provider="copilot" + "classify"
245
264
  // 串)。二期接 sqlite turns 表后这条删/降级,届时改 reason 区分。
246
- if let Some(agents) = state.get("agents").and_then(Value::as_object) {
247
- for (agent_id, agent) in agents {
248
- let is_copilot = agent
249
- .get("provider")
250
- .and_then(Value::as_str)
251
- .and_then(parse_provider)
252
- .is_some_and(|p| matches!(p, crate::model::enums::Provider::Copilot));
253
- if is_copilot {
254
- let _ = event_log.write(
255
- "provider.classify.unsupported",
256
- serde_json::json!({
257
- "provider": "copilot",
258
- "agent_id": agent_id,
259
- "reason": "phase1_unknown_pending_sample",
260
- }),
261
- );
262
- }
265
+ //
266
+ // B-4 P4 / 036b 防 dedup-flood:同 (provider, agent_id, reason) 状态跨 tick
267
+ // 只发一次(check_key 范式,同 abnormal_check_key tick.rs:603 同精神);状态
268
+ // 变了才再发。check_key 落 state.agents.<id>.classify_unsupported.last_key,
269
+ // tick-only metadata,不进 #235 owner / receiver 等持久态。
270
+ let agents_snapshot: Vec<(String, Option<String>)> =
271
+ if let Some(agents) = state.get("agents").and_then(Value::as_object) {
272
+ agents
273
+ .iter()
274
+ .filter_map(|(agent_id, agent)| {
275
+ let is_copilot = agent
276
+ .get("provider")
277
+ .and_then(Value::as_str)
278
+ .and_then(parse_provider)
279
+ .is_some_and(|p| matches!(p, crate::model::enums::Provider::Copilot));
280
+ if !is_copilot {
281
+ return None;
282
+ }
283
+ let last_key = agent
284
+ .get("classify_unsupported")
285
+ .and_then(|v| v.get("last_key"))
286
+ .and_then(Value::as_str)
287
+ .map(str::to_string);
288
+ Some((agent_id.clone(), last_key))
289
+ })
290
+ .collect()
291
+ } else {
292
+ Vec::new()
293
+ };
294
+ for (agent_id, last_key) in agents_snapshot {
295
+ let check_key = format!("copilot|{agent_id}|phase1_unknown_pending_sample");
296
+ if last_key.as_deref() == Some(check_key.as_str()) {
297
+ continue;
263
298
  }
299
+ let _ = event_log.write(
300
+ "provider.classify.unsupported",
301
+ serde_json::json!({
302
+ "provider": "copilot",
303
+ "agent_id": agent_id,
304
+ "reason": "phase1_unknown_pending_sample",
305
+ }),
306
+ );
307
+ mark_classify_unsupported(&mut state, &agent_id, &check_key);
308
+ }
309
+ if let Err(error) = self.detect_abnormal_exits(&mut state, &event_log, &pane_snapshot) {
310
+ let _ = event_log.write(
311
+ "coordinator.tick.detect_abnormal_failed",
312
+ serde_json::json!({"error": error.to_string()}),
313
+ );
264
314
  }
265
- self.detect_abnormal_exits(&mut state, &event_log, &pane_snapshot)?;
266
315
 
267
316
  self.record_step("deliver_pending");
268
317
  let delivered = crate::messaging::deliver_pending_messages(
@@ -389,6 +438,12 @@ impl Coordinator {
389
438
  let team = crate::state::projection::team_state_key(&snapshot);
390
439
  let team_key = Some(crate::model::ids::TeamKey::new(team.clone()));
391
440
  let session_name = state.get("session_name").and_then(Value::as_str).map(str::to_string);
441
+ // B-4 / 036b N36 三路可用 — sync_health 内 per-agent capture 失败本就降级
442
+ // (写 coordinator.agent_capture_failed 后 continue),不打断 deliver_pending
443
+ // 主干。但 contract 要求一条【tick 级】可观测的 step-failed 信号 —
444
+ // sync_health 失败一旦发生就在末尾 emit `coordinator.tick.sync_health_failed`
445
+ // (含 "tick" + "_failed" 双串),避免 silent。
446
+ let mut had_capture_failure = false;
392
447
  // P5 (C-P5-2): one list-windows per SESSION per tick — memoized across the
393
448
  // agent loop instead of one fork per agent.
394
449
  let mut windows_by_session: BTreeMap<String, Result<Vec<crate::transport::WindowName>, String>> =
@@ -409,6 +464,7 @@ impl Coordinator {
409
464
  }) {
410
465
  Ok(windows) => windows.clone(),
411
466
  Err(error) => {
467
+ had_capture_failure = true;
412
468
  event_log.write(
413
469
  "coordinator.agent_capture_failed",
414
470
  serde_json::json!({
@@ -429,6 +485,7 @@ impl Coordinator {
429
485
  {
430
486
  Ok(captured) => captured,
431
487
  Err(error) => {
488
+ had_capture_failure = true;
432
489
  event_log.write(
433
490
  "coordinator.agent_capture_failed",
434
491
  serde_json::json!({
@@ -506,6 +563,15 @@ impl Coordinator {
506
563
  },
507
564
  );
508
565
  }
566
+ // B-4 step-level signal:若本 tick 有任一 capture 失败,emit
567
+ // `coordinator.tick.sync_health_failed`(含 "tick" + "_failed")让 contract
568
+ // 可观测,deliver_pending 主干不受影响。
569
+ if had_capture_failure {
570
+ let _ = event_log.write(
571
+ "coordinator.tick.sync_health_failed",
572
+ serde_json::json!({"step": "sync_health", "degraded": true}),
573
+ );
574
+ }
509
575
  Ok(captures)
510
576
  }
511
577
 
@@ -1738,6 +1804,28 @@ fn mark_abnormal_suppressed(state: &mut Value, agent_id: &str, key: &str) {
1738
1804
  }
1739
1805
  }
1740
1806
 
1807
+ /// B-4 P4 dedup marker — 把 classify.unsupported check_key 写到
1808
+ /// state.agents.<id>.classify_unsupported.last_key,只在状态变了才再发事件。
1809
+ /// 同 abnormal_check_key (line 603) 同精神,但落 agents 子 obj(per-agent locality,
1810
+ /// 不污染 abnormal_exit_watch 命名空间)。
1811
+ fn mark_classify_unsupported(state: &mut Value, agent_id: &str, key: &str) {
1812
+ let Some(agents) = state.get_mut("agents").and_then(Value::as_object_mut) else {
1813
+ return;
1814
+ };
1815
+ let Some(agent) = agents.get_mut(agent_id).and_then(Value::as_object_mut) else {
1816
+ return;
1817
+ };
1818
+ let entry = agent
1819
+ .entry("classify_unsupported".to_string())
1820
+ .or_insert_with(|| serde_json::json!({}));
1821
+ if !entry.is_object() {
1822
+ *entry = serde_json::json!({});
1823
+ }
1824
+ if let Some(obj) = entry.as_object_mut() {
1825
+ obj.insert("last_key".to_string(), serde_json::json!(key));
1826
+ }
1827
+ }
1828
+
1741
1829
  fn mark_abnormal_checked(state: &mut Value, agent_id: &str, key: &str) {
1742
1830
  if let Some(watch) = coordinator_child_object(state, "abnormal_exit_watch") {
1743
1831
  let entry = watch
@@ -84,12 +84,16 @@ pub struct RebuildEvent {
84
84
  }
85
85
 
86
86
  /// `schema_diagnosis` 的只读结论。
87
- #[derive(Debug, Clone, PartialEq, Eq)]
87
+ #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
88
88
  pub struct Diagnosis {
89
89
  pub ok: bool,
90
90
  pub status: String,
91
91
  pub user_version: i64,
92
92
  pub layout_diffs: Vec<String>,
93
+ /// Python parity (`schema_migration.py:188/206`): the layered guidance line —
94
+ /// "missing" carries the initialize_schema first-use hint; drift carries the
95
+ /// fix-schema command; healthy carries "none".
96
+ pub recommended_action: String,
93
97
  }
94
98
 
95
99
  fn table_exists(conn: &Connection, table: &str) -> Result<bool, DbError> {
@@ -316,11 +320,18 @@ pub fn ensure_table_layout(
316
320
  /// `schema_migration.py:schema_diagnosis`:只读判定(不变更 DB)。
317
321
  pub fn schema_diagnosis(db_path: &Path, schema_version: i64) -> Result<Diagnosis, DbError> {
318
322
  if !db_path.exists() {
323
+ // T3-3 cr verdict (A parity lock, 2026-06-10): a missing db is the LEGAL
324
+ // first-use state — ok:true is layered with the explicit status axis and the
325
+ // recommended_action guidance (Python schema_migration.py:180-190 verbatim),
326
+ // never a silent fake-green.
319
327
  return Ok(Diagnosis {
320
328
  ok: true,
321
329
  status: "missing".to_string(),
322
330
  user_version: 0,
323
331
  layout_diffs: vec![],
332
+ recommended_action:
333
+ "No team.db exists yet; initialize_schema will create it on first use."
334
+ .to_string(),
324
335
  });
325
336
  }
326
337
  let conn = Connection::open(db_path)?;
@@ -332,6 +343,11 @@ pub fn schema_diagnosis(db_path: &Path, schema_version: i64) -> Result<Diagnosis
332
343
  ok,
333
344
  status: if ok { "ok".to_string() } else { "schema_repair_available".to_string() },
334
345
  user_version: uv,
346
+ recommended_action: if diff_tables.is_empty() {
347
+ "none".to_string()
348
+ } else {
349
+ "run team-agent doctor --fix-schema --json".to_string()
350
+ },
335
351
  layout_diffs: diff_tables,
336
352
  })
337
353
  }