@team-agent/installer 0.3.13 → 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.
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.13"
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.13"
12
+ version = "0.3.14"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -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();
@@ -494,6 +536,8 @@ pub mod lifecycle_port {
494
536
  "team": team,
495
537
  "session_name": session_name.as_ref().map(|s| s.as_str().to_string()),
496
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<_>>(),
497
541
  "coordinator_status": coordinator_status,
498
542
  "status": status,
499
543
  "phase": phase,
@@ -515,6 +559,8 @@ pub mod lifecycle_port {
515
559
  "team": team,
516
560
  "session_name": session_name.map(|s| s.as_str().to_string()),
517
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<_>>(),
518
564
  "residuals": {
519
565
  "sessions": session_residuals,
520
566
  "processes": process_residuals,
@@ -673,6 +719,33 @@ pub mod lifecycle_port {
673
719
  (sessions, error)
674
720
  }
675
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
+
676
749
  #[derive(Clone, Copy, Debug, Eq, PartialEq)]
677
750
  enum ShutdownReapScope {
678
751
  Workspace,
@@ -721,11 +794,7 @@ pub mod lifecycle_port {
721
794
  /// pids; Gap 37 escalation order TERM -> grace -> KILL preserved), then a single
722
795
  /// bounded wait for the whole union. kill/wait sets derive from the SAME snapshot
723
796
  /// as the protected set (N39).
724
- fn reap_process_tree(
725
- root_pids: &[u32],
726
- protected: &ShutdownProtection,
727
- table: &[ProcessInfo],
728
- ) {
797
+ fn reap_process_tree(root_pids: &[u32], protected: &ShutdownProtection, table: &[ProcessInfo]) {
729
798
  let mut pids = Vec::new();
730
799
  let mut seen = std::collections::BTreeSet::new();
731
800
  for root in root_pids {
@@ -787,12 +856,21 @@ pub mod lifecycle_port {
787
856
  let mut protected = shutdown_protection_set(&round_table);
788
857
  extend_protection_with_leader_panes(&mut protected, transport, state, &round_table);
789
858
  let residuals = matched_processes(
790
- 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,
791
866
  );
792
867
  if residuals.is_empty() {
793
868
  return;
794
869
  }
795
- 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<_>>();
796
874
  reap_process_tree(&residual_pids, &protected, &round_table);
797
875
  let pgids = residuals
798
876
  .iter()
@@ -906,7 +984,9 @@ pub mod lifecycle_port {
906
984
  /// - E4b team-in-team:子 team state 的 team_owner.pane_id 指父 team worker pane;
907
985
  /// 父 team state 的 teams.<child>.team_owner.pane_id 同义(若有该字段)
908
986
  /// → 任一 team 的 shutdown 都不杀任何 team 的 leader 锚 pane
909
- 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> {
910
990
  let mut out = std::collections::BTreeSet::new();
911
991
  push_anchor_pane_id(state, &mut out);
912
992
  if let Some(teams) = state.get("teams").and_then(Value::as_object) {
@@ -1042,7 +1122,10 @@ pub mod lifecycle_port {
1042
1122
  for socket_endpoint in collect_state_recorded_tmux_sockets(state) {
1043
1123
  let cross_backend =
1044
1124
  crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&socket_endpoint);
1045
- 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
+ )
1046
1129
  .unwrap_or_default();
1047
1130
  leader_pane_pids.extend(
1048
1131
  cross_panes
@@ -1167,8 +1250,9 @@ pub mod lifecycle_port {
1167
1250
  scope: ShutdownReapScope,
1168
1251
  table: &[ProcessInfo],
1169
1252
  ) -> Vec<Value> {
1170
- let mut residuals =
1171
- 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
+ );
1172
1256
  let mut seen = residuals
1173
1257
  .iter()
1174
1258
  .map(|process| process.pid)
@@ -1891,7 +1975,7 @@ pub mod lifecycle_port {
1891
1975
  state_path: Some(PathBuf::from("/tmp/state.json")),
1892
1976
  next_actions: vec!["restart".to_string()],
1893
1977
  attach_commands: vec![
1894
- "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()
1895
1979
  ],
1896
1980
  });
1897
1981
  assert_eq!(
@@ -2157,7 +2241,10 @@ pub mod lifecycle_port {
2157
2241
  let msg = "agent start requirement unmet: agent foo not found";
2158
2242
  let na = error_next_action(msg).expect("not-found must carry next_action");
2159
2243
  assert!(na.contains("add-agent"), "must steer to add-agent: {na}");
2160
- 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
+ );
2161
2248
  }
2162
2249
 
2163
2250
  #[test]
@@ -2176,7 +2263,10 @@ pub mod lifecycle_port {
2176
2263
 
2177
2264
  #[test]
2178
2265
  fn unrelated_error_has_no_next_action() {
2179
- 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
+ );
2180
2270
  }
2181
2271
 
2182
2272
  #[test]
@@ -2187,7 +2277,10 @@ pub mod lifecycle_port {
2187
2277
  let v = error_value(err);
2188
2278
  assert_eq!(v["ok"], serde_json::json!(false));
2189
2279
  assert!(
2190
- v["next_action"].as_str().unwrap_or("").contains("add-agent"),
2280
+ v["next_action"]
2281
+ .as_str()
2282
+ .unwrap_or("")
2283
+ .contains("add-agent"),
2191
2284
  "error_value must attach the add-agent guidance: {v}"
2192
2285
  );
2193
2286
  }
@@ -2242,8 +2335,8 @@ pub mod diagnose_port {
2242
2335
  .unwrap_or(true);
2243
2336
  // legacy 降级面(legacy_team_invalid)不下拉整体 ok —— 用户没显式让我们
2244
2337
  // 体检这个 team,失败是降级诊断信息,不是 install 自检失败。
2245
- let legacy_only_failure =
2246
- !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)
2247
2340
  == Some("legacy_team_invalid");
2248
2341
  let effective_smoke_ok = profile_smoke_ok || legacy_only_failure;
2249
2342
  let ok = workspace_valid && (team_context || workspace_has_entries) && effective_smoke_ok;