@team-agent/installer 0.3.12 → 0.3.14

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 (35) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +23 -0
  4. package/crates/team-agent/src/cli/leader.rs +2 -5
  5. package/crates/team-agent/src/cli/mod.rs +258 -79
  6. package/crates/team-agent/src/cli/tests/compile.rs +69 -0
  7. package/crates/team-agent/src/cli/tests/main_preserved.rs +68 -0
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +160 -2
  9. package/crates/team-agent/src/compiler.rs +30 -0
  10. package/crates/team-agent/src/coordinator/tick.rs +16 -83
  11. package/crates/team-agent/src/leader/lease.rs +4 -20
  12. package/crates/team-agent/src/leader/mod.rs +3 -1
  13. package/crates/team-agent/src/leader/owner_bind.rs +46 -48
  14. package/crates/team-agent/src/leader/provider_attribution.rs +214 -0
  15. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -3
  16. package/crates/team-agent/src/leader/rediscover.rs +34 -17
  17. package/crates/team-agent/src/leader/start.rs +279 -4
  18. package/crates/team-agent/src/lifecycle/launch.rs +279 -24
  19. package/crates/team-agent/src/lifecycle/restart/common.rs +50 -40
  20. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +343 -49
  21. package/crates/team-agent/src/lifecycle/restart/selection.rs +9 -6
  22. package/crates/team-agent/src/lifecycle/tests/core.rs +181 -45
  23. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +390 -61
  24. package/crates/team-agent/src/lifecycle/types.rs +28 -0
  25. package/crates/team-agent/src/messaging/delivery.rs +5 -1
  26. package/crates/team-agent/src/messaging/helpers.rs +1 -1
  27. package/crates/team-agent/src/messaging/tests/runtime.rs +14 -0
  28. package/crates/team-agent/src/provider/adapter.rs +6 -3
  29. package/crates/team-agent/src/provider/startup_prompt.rs +173 -0
  30. package/crates/team-agent/src/provider/testdata/copilot-ready-marker.txt +5 -0
  31. package/crates/team-agent/src/provider/testdata/copilot-trust-prompt.txt +19 -0
  32. package/crates/team-agent/src/tmux_backend.rs +5 -0
  33. package/crates/team-agent/src/transport/test_support.rs +22 -7
  34. package/crates/team-agent/src/transport.rs +4 -0
  35. package/package.json +4 -4
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.12"
569
+ version = "0.3.14"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.12"
12
+ version = "0.3.14"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -166,6 +166,7 @@ fn quickstart_human(value: &Value) -> String {
166
166
  pub fn cmd_compile(args: &CompileArgs) -> Result<CmdResult, CliError> {
167
167
  let spec = crate::compiler::compile_team(&args.team)
168
168
  .map_err(|e| CliError::Runtime(e.to_string()))?;
169
+ warn_ignored_owner_team_id(&args.team);
169
170
  std::fs::write(&args.out, crate::model::yaml::dumps(&spec))?;
170
171
  Ok(CmdResult::from_json(
171
172
  json!({
@@ -178,6 +179,28 @@ pub fn cmd_compile(args: &CompileArgs) -> Result<CmdResult, CliError> {
178
179
  ))
179
180
  }
180
181
 
182
+ fn warn_ignored_owner_team_id(team_dir: &std::path::Path) {
183
+ let Ok(Some(ignored)) = crate::compiler::ignored_owner_team_id_from_team_md(team_dir) else {
184
+ return;
185
+ };
186
+ let workspace = crate::model::paths::team_workspace(team_dir)
187
+ .unwrap_or_else(|_| team_dir.parent().unwrap_or(team_dir).to_path_buf());
188
+ eprintln!("Warning: ignored TEAM.md {}={}", ignored.field, ignored.value);
189
+ eprintln!("Reason: owner identity is the canonical runtime team key, not TEAM.md front matter");
190
+ eprintln!("Action: remove {} from TEAM.md", ignored.field);
191
+ let fields = json!({
192
+ "field": ignored.field,
193
+ "source": team_dir.join("TEAM.md").to_string_lossy().to_string(),
194
+ "value": ignored.value,
195
+ "warning": "ignored user-set owner_team_id",
196
+ "reason": "owner identity is derived from the canonical runtime team key",
197
+ "action": "remove owner_team_id from TEAM.md",
198
+ });
199
+ if let Err(err) = crate::event_log::EventLog::new(&workspace).write("spec.field_ignored", fields) {
200
+ eprintln!("Warning: spec.field_ignored event write failed: {err}");
201
+ }
202
+ }
203
+
181
204
  /// `cmd_status`(`commands.py:90`)。三态:`--summary`(xor json,xor agent)→五行文本;
182
205
  /// `--json`→`status_port::status(compact=!detail)`;else→`status_port::format_status(agent)`。
183
206
  pub fn cmd_status(args: &StatusArgs) -> Result<CmdResult, CliError> {
@@ -88,11 +88,8 @@ pub fn cmd_leader_passthrough(
88
88
  }
89
89
 
90
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
- }
91
+ crate::leader::attribute_command_provider(command)
92
+ .unwrap_or(crate::model::enums::Provider::ClaudeCode)
96
93
  }
97
94
 
98
95
  // =============================================================================
@@ -168,48 +168,12 @@ 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();
172
171
  let transport = if let Some(endpoint) = legacy_worker_tmux_endpoint(&state) {
173
172
  crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
174
173
  } else {
175
174
  shutdown_workspace_transport(&run_ws)
176
175
  };
177
- let result =
178
- shutdown_with_transport_and_state(workspace, keep_logs, team, &transport, Some(state));
179
- if team.is_none() {
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).
186
- let transport_dyn: &dyn crate::transport::Transport = &transport;
187
- let pane_targets = transport_dyn.list_targets().unwrap_or_default();
188
- let sessions = socket_session_names(transport_dyn);
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 {
207
- let _ = transport_dyn.kill_session(session);
208
- }
209
- }
210
- }
211
- }
212
- result
176
+ shutdown_with_transport_and_state(workspace, keep_logs, team, &transport, Some(state))
213
177
  }
214
178
 
215
179
  /// E12 ①:从 state 锚 pane_id(leader_receiver/team_owner,top+teams)映射到其所在 session
@@ -235,15 +199,13 @@ pub mod lifecycle_port {
235
199
  .collect()
236
200
  }
237
201
 
238
- fn socket_session_names(
239
- transport: &dyn crate::transport::Transport,
202
+ fn socket_session_names_from_targets(
203
+ pane_targets: &[crate::transport::PaneInfo],
240
204
  ) -> Vec<crate::transport::SessionName> {
241
205
  let mut seen = std::collections::BTreeSet::new();
242
- transport
243
- .list_targets()
244
- .unwrap_or_default()
245
- .into_iter()
246
- .map(|pane| pane.session)
206
+ pane_targets
207
+ .iter()
208
+ .map(|pane| pane.session.clone())
247
209
  .filter(|session| seen.insert(session.as_str().to_string()))
248
210
  .collect()
249
211
  }
@@ -281,6 +243,76 @@ pub mod lifecycle_port {
281
243
  }
282
244
  }
283
245
 
246
+ #[derive(Debug, Default)]
247
+ struct ShutdownSocketCleanup {
248
+ killed_sessions: Vec<crate::transport::SessionName>,
249
+ spared_sessions: Vec<crate::transport::SessionName>,
250
+ error: Option<String>,
251
+ }
252
+
253
+ fn push_unique_session(
254
+ sessions: &mut Vec<crate::transport::SessionName>,
255
+ session: crate::transport::SessionName,
256
+ ) {
257
+ if !sessions
258
+ .iter()
259
+ .any(|existing| existing.as_str() == session.as_str())
260
+ {
261
+ sessions.push(session);
262
+ }
263
+ }
264
+
265
+ fn bare_shutdown_socket_cleanup(
266
+ transport: &dyn crate::transport::Transport,
267
+ state: &Value,
268
+ event_log: &crate::event_log::EventLog,
269
+ ) -> ShutdownSocketCleanup {
270
+ // E12 (P0): the leader terminal lives on this socket by design. A bare shutdown must
271
+ // NOT `kill-server` it away. spare = state-anchor sessions ∪ `team-agent-leader-*`
272
+ // prefix sessions (union; cr E12 ①). kill_server only when the socket is exclusively
273
+ // ours (no spare + no foreign session); shared socket → kill our sessions individually
274
+ // (cr E12 ②). All spare derivation comes from ONE snapshot (list_targets + state) —
275
+ // no independent ps/tmux re-derivation (N39).
276
+ let pane_targets = transport.list_targets().unwrap_or_default();
277
+ let sessions = socket_session_names_from_targets(&pane_targets);
278
+ let anchor_sessions = anchor_sessions_from_state(state, &pane_targets, event_log);
279
+ match sessions_to_kill(&sessions, &anchor_sessions) {
280
+ KillDecision::KillServerExclusive => {
281
+ let error = transport.kill_server().err().map(|error| error.to_string());
282
+ ShutdownSocketCleanup {
283
+ killed_sessions: sessions,
284
+ spared_sessions: Vec::new(),
285
+ error,
286
+ }
287
+ }
288
+ KillDecision::KillIndividually { to_kill, spared } => {
289
+ if !spared.is_empty() || to_kill.len() != sessions.len() {
290
+ // shared socket / leader spared → never whole-server teardown.
291
+ let _ = event_log.write(
292
+ "shutdown.kill_server_skipped_shared_socket",
293
+ json!({
294
+ "spared_sessions": spared.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
295
+ "killed_sessions": to_kill.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
296
+ }),
297
+ );
298
+ }
299
+ let mut error = None;
300
+ for session in &to_kill {
301
+ if let Err(err) = transport.kill_session(session) {
302
+ if !tmux_absent_error(&err.to_string()) {
303
+ error.get_or_insert_with(|| err.to_string());
304
+ }
305
+ }
306
+ }
307
+ ShutdownSocketCleanup {
308
+ killed_sessions: to_kill,
309
+ spared_sessions: spared,
310
+ error,
311
+ }
312
+ }
313
+ }
314
+ }
315
+
284
316
  pub fn shutdown_with_transport(
285
317
  workspace: &Path,
286
318
  keep_logs: bool,
@@ -358,8 +390,11 @@ pub mod lifecycle_port {
358
390
  reap_process_tree(&root_pids, &protected, &entry_table);
359
391
  reap_process_groups(&root_pgids, &protected);
360
392
  let mut kill_error: Option<String> = None;
393
+ let mut killed_sessions = Vec::new();
394
+ let mut spared_sessions = Vec::new();
361
395
  deadline.check("kill_session")?;
362
396
  if let Some(session) = session_name.as_ref() {
397
+ push_unique_session(&mut killed_sessions, session.clone());
363
398
  if let Err(error) = transport.kill_session(session) {
364
399
  if !tmux_absent_error(&error.to_string()) {
365
400
  kill_error = Some(error.to_string());
@@ -376,21 +411,28 @@ pub mod lifecycle_port {
376
411
  reap_scope,
377
412
  &mut probe_degraded,
378
413
  );
379
- deadline.check("session_residuals")?;
380
- let session_residuals = if let Some(session) = session_name.as_ref() {
381
- let (residuals, error) = session_residuals_after_reap(
382
- transport,
383
- &run_workspace,
384
- session,
385
- !captured_missing_sessions,
386
- );
387
- if let Some(error) = error {
414
+ if team.is_none() {
415
+ deadline.check("shared_socket_cleanup")?;
416
+ let event_log = crate::event_log::EventLog::new(&run_workspace);
417
+ let cleanup = bare_shutdown_socket_cleanup(transport, &state, &event_log);
418
+ for session in cleanup.killed_sessions {
419
+ push_unique_session(&mut killed_sessions, session);
420
+ }
421
+ spared_sessions = cleanup.spared_sessions;
422
+ if let Some(error) = cleanup.error {
388
423
  kill_error.get_or_insert(error);
389
424
  }
390
- residuals
391
- } else {
392
- Vec::new()
393
- };
425
+ }
426
+ deadline.check("session_residuals")?;
427
+ let (session_residuals, session_residual_error) = session_residuals_after_reap_many(
428
+ transport,
429
+ &run_workspace,
430
+ &killed_sessions,
431
+ !captured_missing_sessions,
432
+ );
433
+ if let Some(error) = session_residual_error {
434
+ kill_error.get_or_insert(error);
435
+ }
394
436
  deadline.check("process_residuals")?;
395
437
  // C-①: the post-verify gets ONE fresh verification snapshot (reaps changed
396
438
  // the world; #248 post-verify facts must be current, not the entry view).
@@ -427,7 +469,7 @@ pub mod lifecycle_port {
427
469
  // swallow batch 1: a failed ps probe degrades verification truthfully — the
428
470
  // empty table must never read as a clean "no residual processes".
429
471
  let verification_degraded = probe_timeout.is_some() || probe_degraded;
430
- let session_killed = session_name.is_some()
472
+ let session_killed = !killed_sessions.is_empty()
431
473
  && kill_error.is_none()
432
474
  && session_residuals.is_empty()
433
475
  && process_residuals.is_empty();
@@ -452,7 +494,9 @@ pub mod lifecycle_port {
452
494
  let coordinator_pid = stopped
453
495
  .as_ref()
454
496
  .and_then(|stopped| stopped.pid.map(|p| p.get()));
455
- let ok = stopped.as_ref().map(|stopped| stopped.ok).unwrap_or(true)
497
+ let coordinator_clean =
498
+ !coordinator_timeout && stopped.as_ref().map(|stopped| stopped.ok).unwrap_or(true);
499
+ let ok = coordinator_clean
456
500
  && kill_error.is_none()
457
501
  && session_residuals.is_empty()
458
502
  && process_residuals.is_empty()
@@ -492,6 +536,8 @@ pub mod lifecycle_port {
492
536
  "team": team,
493
537
  "session_name": session_name.as_ref().map(|s| s.as_str().to_string()),
494
538
  "session_killed": session_killed,
539
+ "killed_sessions": killed_sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
540
+ "spared_sessions": spared_sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
495
541
  "coordinator_status": coordinator_status,
496
542
  "status": status,
497
543
  "phase": phase,
@@ -513,6 +559,8 @@ pub mod lifecycle_port {
513
559
  "team": team,
514
560
  "session_name": session_name.map(|s| s.as_str().to_string()),
515
561
  "session_killed": session_killed,
562
+ "killed_sessions": killed_sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
563
+ "spared_sessions": spared_sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
516
564
  "residuals": {
517
565
  "sessions": session_residuals,
518
566
  "processes": process_residuals,
@@ -671,6 +719,33 @@ pub mod lifecycle_port {
671
719
  (sessions, error)
672
720
  }
673
721
 
722
+ fn session_residuals_after_reap_many(
723
+ transport: &dyn crate::transport::Transport,
724
+ workspace: &Path,
725
+ sessions: &[crate::transport::SessionName],
726
+ check_primary_transport: bool,
727
+ ) -> (Vec<String>, Option<String>) {
728
+ let mut residuals = Vec::new();
729
+ let mut error = None;
730
+ for session in sessions {
731
+ let (session_residuals, session_error) = session_residuals_after_reap(
732
+ transport,
733
+ workspace,
734
+ session,
735
+ check_primary_transport,
736
+ );
737
+ for residual in session_residuals {
738
+ if !residuals.iter().any(|seen| seen == &residual) {
739
+ residuals.push(residual);
740
+ }
741
+ }
742
+ if let Some(session_error) = session_error {
743
+ error.get_or_insert(session_error);
744
+ }
745
+ }
746
+ (residuals, error)
747
+ }
748
+
674
749
  #[derive(Clone, Copy, Debug, Eq, PartialEq)]
675
750
  enum ShutdownReapScope {
676
751
  Workspace,
@@ -719,11 +794,7 @@ pub mod lifecycle_port {
719
794
  /// pids; Gap 37 escalation order TERM -> grace -> KILL preserved), then a single
720
795
  /// bounded wait for the whole union. kill/wait sets derive from the SAME snapshot
721
796
  /// as the protected set (N39).
722
- fn reap_process_tree(
723
- root_pids: &[u32],
724
- protected: &ShutdownProtection,
725
- table: &[ProcessInfo],
726
- ) {
797
+ fn reap_process_tree(root_pids: &[u32], protected: &ShutdownProtection, table: &[ProcessInfo]) {
727
798
  let mut pids = Vec::new();
728
799
  let mut seen = std::collections::BTreeSet::new();
729
800
  for root in root_pids {
@@ -785,12 +856,21 @@ pub mod lifecycle_port {
785
856
  let mut protected = shutdown_protection_set(&round_table);
786
857
  extend_protection_with_leader_panes(&mut protected, transport, state, &round_table);
787
858
  let residuals = matched_processes(
788
- workspace, state, root_pids, root_pgids, &protected, scope, &round_table,
859
+ workspace,
860
+ state,
861
+ root_pids,
862
+ root_pgids,
863
+ &protected,
864
+ scope,
865
+ &round_table,
789
866
  );
790
867
  if residuals.is_empty() {
791
868
  return;
792
869
  }
793
- let residual_pids = residuals.iter().map(|process| process.pid).collect::<Vec<_>>();
870
+ let residual_pids = residuals
871
+ .iter()
872
+ .map(|process| process.pid)
873
+ .collect::<Vec<_>>();
794
874
  reap_process_tree(&residual_pids, &protected, &round_table);
795
875
  let pgids = residuals
796
876
  .iter()
@@ -904,7 +984,9 @@ pub mod lifecycle_port {
904
984
  /// - E4b team-in-team:子 team state 的 team_owner.pane_id 指父 team worker pane;
905
985
  /// 父 team state 的 teams.<child>.team_owner.pane_id 同义(若有该字段)
906
986
  /// → 任一 team 的 shutdown 都不杀任何 team 的 leader 锚 pane
907
- pub fn collect_state_leader_anchor_pane_ids(state: &Value) -> std::collections::BTreeSet<String> {
987
+ pub fn collect_state_leader_anchor_pane_ids(
988
+ state: &Value,
989
+ ) -> std::collections::BTreeSet<String> {
908
990
  let mut out = std::collections::BTreeSet::new();
909
991
  push_anchor_pane_id(state, &mut out);
910
992
  if let Some(teams) = state.get("teams").and_then(Value::as_object) {
@@ -1040,7 +1122,10 @@ pub mod lifecycle_port {
1040
1122
  for socket_endpoint in collect_state_recorded_tmux_sockets(state) {
1041
1123
  let cross_backend =
1042
1124
  crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&socket_endpoint);
1043
- let cross_panes = <crate::tmux_backend::TmuxBackend as crate::transport::Transport>::list_targets(&cross_backend)
1125
+ let cross_panes =
1126
+ <crate::tmux_backend::TmuxBackend as crate::transport::Transport>::list_targets(
1127
+ &cross_backend,
1128
+ )
1044
1129
  .unwrap_or_default();
1045
1130
  leader_pane_pids.extend(
1046
1131
  cross_panes
@@ -1165,8 +1250,9 @@ pub mod lifecycle_port {
1165
1250
  scope: ShutdownReapScope,
1166
1251
  table: &[ProcessInfo],
1167
1252
  ) -> Vec<Value> {
1168
- let mut residuals =
1169
- matched_processes(workspace, state, root_pids, root_pgids, protected, scope, table);
1253
+ let mut residuals = matched_processes(
1254
+ workspace, state, root_pids, root_pgids, protected, scope, table,
1255
+ );
1170
1256
  let mut seen = residuals
1171
1257
  .iter()
1172
1258
  .map(|process| process.pid)
@@ -1484,9 +1570,28 @@ pub mod lifecycle_port {
1484
1570
  }
1485
1571
  let agent_id = crate::model::ids::AgentId::new(agent);
1486
1572
  match crate::lifecycle::remove_agent(workspace, &agent_id, from_spec, force, team) {
1487
- Ok(report) => {
1488
- Ok(json!({"ok": true, "agent_id": agent, "report": format!("{report:?}")}))
1489
- }
1573
+ Ok(report @ crate::lifecycle::RemoveAgentOutcome::Removed { .. }) => Ok(json!({
1574
+ "ok": true,
1575
+ "agent_id": agent,
1576
+ "status": "removed",
1577
+ "report": format!("{report:?}"),
1578
+ })),
1579
+ Ok(crate::lifecycle::RemoveAgentOutcome::RefusedFromSpecConfirm { .. }) => Ok(json!({
1580
+ "ok": false,
1581
+ "agent_id": agent,
1582
+ "status": "refused",
1583
+ "reason": "from_spec_confirm_required",
1584
+ "error": "remove-agent requires --from-spec --confirm for spec-defined agents",
1585
+ "action": "rerun with --from-spec --confirm, or omit --from-spec only for dynamic agents",
1586
+ })),
1587
+ Ok(crate::lifecycle::RemoveAgentOutcome::RefusedForceRequired { .. }) => Ok(json!({
1588
+ "ok": false,
1589
+ "agent_id": agent,
1590
+ "status": "refused",
1591
+ "reason": "force_required",
1592
+ "error": "agent is running; remove-agent requires --force",
1593
+ "action": "rerun with --force to stop and remove the running agent",
1594
+ })),
1490
1595
  Err(e) => Ok(error_value(e)),
1491
1596
  }
1492
1597
  }
@@ -1870,7 +1975,7 @@ pub mod lifecycle_port {
1870
1975
  state_path: Some(PathBuf::from("/tmp/state.json")),
1871
1976
  next_actions: vec!["restart".to_string()],
1872
1977
  attach_commands: vec![
1873
- "tmux -S /tmp/tmux-501/ta-test attach -t team-teamA:worker".to_string(),
1978
+ "tmux -S /tmp/tmux-501/ta-test attach -t team-teamA:worker".to_string()
1874
1979
  ],
1875
1980
  });
1876
1981
  assert_eq!(
@@ -1913,6 +2018,71 @@ pub mod lifecycle_port {
1913
2018
  "next_actions": next_actions,
1914
2019
  "attach_commands": attach_commands,
1915
2020
  }),
2021
+ crate::lifecycle::RestartReport::Partial {
2022
+ session_name,
2023
+ agents,
2024
+ failed_agents,
2025
+ coordinator_started,
2026
+ next_actions,
2027
+ attach_commands,
2028
+ } => json!({
2029
+ "ok": false,
2030
+ "status": "partial",
2031
+ "reason": "restart_agent_failed",
2032
+ "session_name": session_name.as_str(),
2033
+ "agents": agents.iter().map(|a| a.agent_id.as_str()).collect::<Vec<_>>(),
2034
+ "failed_agents": failed_agents.iter().map(|failure| json!({
2035
+ "agent_id": failure.agent_id.as_str(),
2036
+ "restart_mode": failure.restart_mode,
2037
+ "decision": failure.decision,
2038
+ "session_id": failure.session_id.as_ref().map(|session| session.as_str()),
2039
+ "phase": failure.phase,
2040
+ "error": failure.error,
2041
+ "action": format!(
2042
+ "inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
2043
+ failure.agent_id,
2044
+ failure.agent_id
2045
+ ),
2046
+ "log": format!(
2047
+ ".team/logs/coordinator.log and .team/runtime/state.json agent={}",
2048
+ failure.agent_id
2049
+ ),
2050
+ })).collect::<Vec<_>>(),
2051
+ "coordinator_started": coordinator_started,
2052
+ "next_actions": next_actions,
2053
+ "attach_commands": attach_commands,
2054
+ }),
2055
+ crate::lifecycle::RestartReport::Failed {
2056
+ session_name,
2057
+ failed_agents,
2058
+ next_actions,
2059
+ attach_commands,
2060
+ } => json!({
2061
+ "ok": false,
2062
+ "status": "failed",
2063
+ "reason": "restart_all_agents_failed",
2064
+ "session_name": session_name.as_str(),
2065
+ "agents": [],
2066
+ "failed_agents": failed_agents.iter().map(|failure| json!({
2067
+ "agent_id": failure.agent_id.as_str(),
2068
+ "restart_mode": failure.restart_mode,
2069
+ "decision": failure.decision,
2070
+ "session_id": failure.session_id.as_ref().map(|session| session.as_str()),
2071
+ "phase": failure.phase,
2072
+ "error": failure.error,
2073
+ "action": format!(
2074
+ "inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
2075
+ failure.agent_id,
2076
+ failure.agent_id
2077
+ ),
2078
+ "log": format!(
2079
+ ".team/logs/coordinator.log and .team/runtime/state.json agent={}",
2080
+ failure.agent_id
2081
+ ),
2082
+ })).collect::<Vec<_>>(),
2083
+ "next_actions": next_actions,
2084
+ "attach_commands": attach_commands,
2085
+ }),
1916
2086
  crate::lifecycle::RestartReport::RefusedResumeAtomicity {
1917
2087
  unresumable,
1918
2088
  allow_fresh,
@@ -2071,7 +2241,10 @@ pub mod lifecycle_port {
2071
2241
  let msg = "agent start requirement unmet: agent foo not found";
2072
2242
  let na = error_next_action(msg).expect("not-found must carry next_action");
2073
2243
  assert!(na.contains("add-agent"), "must steer to add-agent: {na}");
2074
- assert!(na.contains("--role-file"), "must show the role-file flag: {na}");
2244
+ assert!(
2245
+ na.contains("--role-file"),
2246
+ "must show the role-file flag: {na}"
2247
+ );
2075
2248
  }
2076
2249
 
2077
2250
  #[test]
@@ -2090,7 +2263,10 @@ pub mod lifecycle_port {
2090
2263
 
2091
2264
  #[test]
2092
2265
  fn unrelated_error_has_no_next_action() {
2093
- assert_eq!(error_next_action("state persistence failed: disk full"), None);
2266
+ assert_eq!(
2267
+ error_next_action("state persistence failed: disk full"),
2268
+ None
2269
+ );
2094
2270
  }
2095
2271
 
2096
2272
  #[test]
@@ -2101,7 +2277,10 @@ pub mod lifecycle_port {
2101
2277
  let v = error_value(err);
2102
2278
  assert_eq!(v["ok"], serde_json::json!(false));
2103
2279
  assert!(
2104
- v["next_action"].as_str().unwrap_or("").contains("add-agent"),
2280
+ v["next_action"]
2281
+ .as_str()
2282
+ .unwrap_or("")
2283
+ .contains("add-agent"),
2105
2284
  "error_value must attach the add-agent guidance: {v}"
2106
2285
  );
2107
2286
  }
@@ -2156,8 +2335,8 @@ pub mod diagnose_port {
2156
2335
  .unwrap_or(true);
2157
2336
  // legacy 降级面(legacy_team_invalid)不下拉整体 ok —— 用户没显式让我们
2158
2337
  // 体检这个 team,失败是降级诊断信息,不是 install 自检失败。
2159
- let legacy_only_failure =
2160
- !profile_smoke_ok && profile_smoke_value.get("status").and_then(Value::as_str)
2338
+ let legacy_only_failure = !profile_smoke_ok
2339
+ && profile_smoke_value.get("status").and_then(Value::as_str)
2161
2340
  == Some("legacy_team_invalid");
2162
2341
  let effective_smoke_ok = profile_smoke_ok || legacy_only_failure;
2163
2342
  let ok = workspace_valid && (team_context || workspace_has_entries) && effective_smoke_ok;
@@ -16,6 +16,16 @@ fn compile_team_dir(tag: &str) -> std::path::PathBuf {
16
16
  team
17
17
  }
18
18
 
19
+ fn compile_team_dir_with_owner_team_id(tag: &str) -> std::path::PathBuf {
20
+ let team = compile_team_dir(tag);
21
+ std::fs::write(
22
+ team.join("TEAM.md"),
23
+ "---\nname: compileteam\nobjective: Compile probe.\nprovider: fake\nowner_team_id: user-set-owner\n---\n\nCompile team.\n",
24
+ )
25
+ .unwrap();
26
+ team
27
+ }
28
+
19
29
  #[test]
20
30
  fn cmd_compile_json_and_human_match_golden_shape_and_writes_out() {
21
31
  let team = compile_team_dir("compile-ok");
@@ -47,6 +57,65 @@ fn cmd_compile_json_and_human_match_golden_shape_and_writes_out() {
47
57
  assert_eq!(human_text, expected_human, "golden human output preserves cmd_compile insertion order");
48
58
  }
49
59
 
60
+ #[test]
61
+ fn cmd_compile_ignores_user_owner_team_id_and_emits_warning_event() {
62
+ let team = compile_team_dir_with_owner_team_id("compile-ignored-owner");
63
+ let workspace = team.parent().unwrap().to_path_buf();
64
+ let out = workspace.join("ignored-owner-out.yaml");
65
+ let args = CompileArgs { team: team.clone(), out: out.clone(), json: true };
66
+
67
+ let result = cmd_compile(&args).expect("compile");
68
+ assert_eq!(result.exit, ExitCode::Ok);
69
+ let compiled = std::fs::read_to_string(&out).unwrap();
70
+ assert!(
71
+ !compiled.contains("owner_team_id"),
72
+ "user-set owner_team_id must not be compiled into team.spec.yaml"
73
+ );
74
+ let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
75
+ let event = events
76
+ .iter()
77
+ .find(|event| {
78
+ event
79
+ .get("event")
80
+ .and_then(serde_json::Value::as_str)
81
+ == Some("spec.field_ignored")
82
+ })
83
+ .expect("owner_team_id warning event");
84
+ assert_eq!(
85
+ event.get("field").and_then(serde_json::Value::as_str),
86
+ Some("owner_team_id")
87
+ );
88
+ assert_eq!(
89
+ event.get("value").and_then(serde_json::Value::as_str),
90
+ Some("user-set-owner")
91
+ );
92
+ assert_eq!(
93
+ event.get("action").and_then(serde_json::Value::as_str),
94
+ Some("remove owner_team_id from TEAM.md")
95
+ );
96
+ }
97
+
98
+ #[test]
99
+ fn cmd_compile_without_owner_team_id_emits_no_ignored_field_event() {
100
+ let team = compile_team_dir("compile-no-ignored-owner");
101
+ let workspace = team.parent().unwrap().to_path_buf();
102
+ let out = workspace.join("no-ignored-owner-out.yaml");
103
+ let args = CompileArgs { team: team.clone(), out, json: true };
104
+
105
+ let result = cmd_compile(&args).expect("compile");
106
+ assert_eq!(result.exit, ExitCode::Ok);
107
+ let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
108
+ assert!(
109
+ events.iter().all(|event| {
110
+ event
111
+ .get("event")
112
+ .and_then(serde_json::Value::as_str)
113
+ != Some("spec.field_ignored")
114
+ }),
115
+ "TEAM.md without owner_team_id must not emit ignored-field warning"
116
+ );
117
+ }
118
+
50
119
  #[test]
51
120
  fn run_dispatches_compile_and_error_path_exits_error() {
52
121
  let team = compile_team_dir("compile-dispatch");