@team-agent/installer 0.3.15 → 0.3.17

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.
@@ -161,6 +161,7 @@ struct CleanShutdownTransport {
161
161
  targets: Vec<PaneInfo>,
162
162
  kill_server_called: Mutex<bool>,
163
163
  probe_timeout_kind: Option<&'static str>,
164
+ targets_persist_after_kill: bool,
164
165
  }
165
166
 
166
167
  impl CleanShutdownTransport {
@@ -170,6 +171,7 @@ impl CleanShutdownTransport {
170
171
  targets: Vec::new(),
171
172
  kill_server_called: Mutex::new(false),
172
173
  probe_timeout_kind: None,
174
+ targets_persist_after_kill: false,
173
175
  }
174
176
  }
175
177
 
@@ -186,6 +188,11 @@ impl CleanShutdownTransport {
186
188
  self.probe_timeout_kind = Some(probe);
187
189
  self
188
190
  }
191
+
192
+ fn with_targets_persist_after_kill(mut self) -> Self {
193
+ self.targets_persist_after_kill = true;
194
+ self
195
+ }
189
196
  }
190
197
 
191
198
  impl Transport for CleanShutdownTransport {
@@ -249,7 +256,7 @@ impl Transport for CleanShutdownTransport {
249
256
  }
250
257
 
251
258
  fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
252
- if *self.session_present.lock().unwrap() {
259
+ if *self.session_present.lock().unwrap() || self.targets_persist_after_kill {
253
260
  Ok(self.targets.clone())
254
261
  } else {
255
262
  Ok(Vec::new())
@@ -375,6 +382,85 @@ fn ps_table_timeout_still_degrades_shutdown_truth() {
375
382
  assert_eq!(out["probe_timeout_kind"], json!("ps_table"));
376
383
  }
377
384
 
385
+ #[test]
386
+ fn bounded_coordinator_stop_returns_grace_window_late_success() {
387
+ let ws = tmp_shutdown_workspace("late-coordinator-stop-success");
388
+ let report = crate::cli::lifecycle_port::stop_coordinator_bounded_with(
389
+ crate::coordinator::WorkspacePath::new(ws),
390
+ std::time::Duration::from_millis(5),
391
+ |_workspace| {
392
+ std::thread::sleep(std::time::Duration::from_millis(25));
393
+ Ok(crate::coordinator::StopReport {
394
+ ok: true,
395
+ status: crate::coordinator::StopOutcome::Stopped,
396
+ pid: Some(crate::coordinator::Pid::new(12345)),
397
+ })
398
+ },
399
+ )
400
+ .expect("late result inside grace window must be returned")
401
+ .expect("stop result should be ok");
402
+
403
+ assert!(report.ok, "late success must not be discarded as timeout");
404
+ assert_eq!(report.status, crate::coordinator::StopOutcome::Stopped);
405
+ }
406
+
407
+ #[test]
408
+ fn shutdown_outcome_late_or_postcheck_gone_is_ok_with_lsof_diagnostic() {
409
+ let out = crate::cli::lifecycle_port::classify_shutdown_outcome(
410
+ crate::cli::lifecycle_port::ShutdownOutcomeInput {
411
+ kill_error: false,
412
+ session_residuals: false,
413
+ process_residuals: false,
414
+ cleanup_truth_degraded: false,
415
+ coordinator_timeout: true,
416
+ coordinator_stop_ok: None,
417
+ coordinator_post_stop: crate::cli::lifecycle_port::CoordinatorStopObservation::Gone,
418
+ },
419
+ );
420
+
421
+ assert!(out.ok);
422
+ assert_eq!(out.status, "ok");
423
+ assert_eq!(out.phase, None);
424
+ }
425
+
426
+ #[test]
427
+ fn shutdown_outcome_coordinator_timeout_still_running_is_timeout() {
428
+ let out = crate::cli::lifecycle_port::classify_shutdown_outcome(
429
+ crate::cli::lifecycle_port::ShutdownOutcomeInput {
430
+ kill_error: false,
431
+ session_residuals: false,
432
+ process_residuals: false,
433
+ cleanup_truth_degraded: false,
434
+ coordinator_timeout: true,
435
+ coordinator_stop_ok: None,
436
+ coordinator_post_stop: crate::cli::lifecycle_port::CoordinatorStopObservation::Running,
437
+ },
438
+ );
439
+
440
+ assert!(!out.ok);
441
+ assert_eq!(out.status, "timeout");
442
+ assert_eq!(out.phase, Some("stop_coordinator"));
443
+ }
444
+
445
+ #[test]
446
+ fn shutdown_outcome_ps_table_degraded_still_partial_after_coordinator_gone() {
447
+ let out = crate::cli::lifecycle_port::classify_shutdown_outcome(
448
+ crate::cli::lifecycle_port::ShutdownOutcomeInput {
449
+ kill_error: false,
450
+ session_residuals: false,
451
+ process_residuals: false,
452
+ cleanup_truth_degraded: true,
453
+ coordinator_timeout: true,
454
+ coordinator_stop_ok: None,
455
+ coordinator_post_stop: crate::cli::lifecycle_port::CoordinatorStopObservation::Gone,
456
+ },
457
+ );
458
+
459
+ assert!(!out.ok);
460
+ assert_eq!(out.status, "partial");
461
+ assert_eq!(out.phase, Some("os_probe"));
462
+ }
463
+
378
464
  #[test]
379
465
  fn leader_env_tmux_socket_never_kills_server_even_when_sessions_look_exclusive() {
380
466
  let ws = tmp_shutdown_workspace("leader-env-socket-no-kill-server");
@@ -417,3 +503,43 @@ fn leader_env_tmux_socket_never_kills_server_even_when_sessions_look_exclusive()
417
503
  "leader-env/shared socket shutdown must kill sessions individually, never kill-server"
418
504
  );
419
505
  }
506
+
507
+ #[test]
508
+ fn managed_leader_shutdown_never_kills_server_even_when_socket_looks_exclusive() {
509
+ let ws = tmp_shutdown_workspace("managed-leader-no-kill-server");
510
+ crate::state::persist::save_runtime_state(
511
+ &ws,
512
+ &json!({
513
+ "session_name": "team-current",
514
+ "is_external_leader": false,
515
+ "agents": {}
516
+ }),
517
+ )
518
+ .unwrap();
519
+ let transport = CleanShutdownTransport::new()
520
+ .with_targets(vec![PaneInfo {
521
+ pane_id: PaneId::new("%1"),
522
+ session: SessionName::new("team-current"),
523
+ window_index: Some(0),
524
+ window_name: Some(WindowName::new("leader")),
525
+ pane_index: Some(0),
526
+ tty: None,
527
+ current_command: Some("codex".to_string()),
528
+ current_path: None,
529
+ active: true,
530
+ pane_pid: None,
531
+ leader_env: BTreeMap::new(),
532
+ }])
533
+ .with_targets_persist_after_kill();
534
+
535
+ let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
536
+ .expect("shutdown should complete");
537
+
538
+ assert_eq!(out["ok"], json!(true));
539
+ assert_eq!(out["killed_sessions"], json!(["team-current"]));
540
+ assert_eq!(out["spared_sessions"], json!([]));
541
+ assert!(
542
+ !transport.kill_server_called(),
543
+ "managed topology must clear the team session/window without kill-server"
544
+ );
545
+ }
@@ -36,6 +36,10 @@ use super::*;
36
36
  for key in [
37
37
  "team",
38
38
  "session_name",
39
+ "leader_topology",
40
+ "is_external_leader",
41
+ "leader_attach_command",
42
+ "leader_client",
39
43
  "tmux_session_present",
40
44
  "leader_receiver",
41
45
  "agents",
@@ -61,6 +65,27 @@ use super::*;
61
65
  let _ = std::fs::remove_dir_all(&ws);
62
66
  }
63
67
 
68
+ #[test]
69
+ fn status_port_status_reports_managed_leader_topology_and_attach_command() {
70
+ let ws = seed_status_workspace();
71
+ let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
72
+ if let Some(obj) = state.as_object_mut() {
73
+ obj.insert("is_external_leader".to_string(), json!(false));
74
+ obj.insert("session_name".to_string(), json!("team-current"));
75
+ }
76
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
77
+
78
+ let v = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false).expect("status");
79
+
80
+ assert_eq!(v["leader_topology"], json!("managed"));
81
+ assert_eq!(v["is_external_leader"], json!(false));
82
+ let attach = v["leader_attach_command"]
83
+ .as_str()
84
+ .expect("managed status includes attach command");
85
+ assert!(attach.contains("attach -t team-current:leader"), "{attach}");
86
+ let _ = std::fs::remove_dir_all(&ws);
87
+ }
88
+
64
89
  #[test]
65
90
  fn status_port_status_detail_full_keeps_uncompacted_events() {
66
91
  // cmd_status --json --detail -> status_port::status(compact=false): the FULL dict
@@ -202,6 +202,8 @@ pub struct LeaderLauncherArgs {
202
202
  pub confirm_attach: bool,
203
203
  /// `--attach-session <name>` / `--attach-session=<name>`。
204
204
  pub attach_session: Option<String>,
205
+ /// 0.3.16 topology opt-out: keep the old external/current-pane leader launcher path.
206
+ pub external_leader: bool,
205
207
  }
206
208
 
207
209
  // =============================================================================
@@ -1,6 +1,7 @@
1
1
  //! leader::lease — attach / claim / autobind 统一 CAS 路径 + claim_lease_no_incident
2
2
  //! + 双写 / 分叉检测。
3
3
 
4
+ use std::collections::BTreeSet;
4
5
  use std::path::Path;
5
6
 
6
7
  use serde_json::{json, Value};
@@ -25,27 +26,34 @@ use super::{
25
26
  /// 整段临界区做 state 变更 + 事件 + 双写 + requeue exhausted watchers。
26
27
  pub fn attach_leader(
27
28
  workspace: &Path,
29
+ team: Option<&str>,
28
30
  pane: Option<&PaneId>,
29
31
  provider: Provider,
30
32
  ) -> Result<LeaseResult, LeaderError> {
31
33
  let event_log = crate::event_log::EventLog::new(workspace);
32
- let mut state = crate::state::persist::load_runtime_state(workspace)?;
33
- let targets = crate::tmux_backend::TmuxBackend::for_workspace(workspace)
34
- .list_targets()
35
- .unwrap_or_default();
34
+ let scoped_team = team.filter(|value| !value.is_empty());
35
+ let mut state = if scoped_team.is_some() {
36
+ crate::state::projection::select_runtime_state(workspace, scoped_team)?
37
+ } else {
38
+ crate::state::persist::load_runtime_state(workspace)?
39
+ };
40
+ let targets = attach_leader_targets(workspace, &state);
36
41
  let pane_id = pane
37
42
  .cloned()
38
43
  .or_else(|| std::env::var("TMUX_PANE").ok().filter(|p| !p.is_empty()).map(PaneId::new))
39
44
  .ok_or_else(|| LeaderError::Validation("tmux pane not found".to_string()))?;
40
45
  let non_empty_pane_id = NonEmptyPaneId::try_from_pane(&pane_id)?;
41
- let Some(target) = targets.iter().find(|target| target.pane_id == pane_id) else {
46
+ let Some(target) = targets.iter().find(|target| target.info.pane_id == pane_id) else {
42
47
  return Err(LeaderError::Validation(format!("tmux pane not found: {pane_id}")));
43
48
  };
44
- let mut receiver = receiver_for_attach_target(workspace, &state, target, provider, Discovery::ExplicitPane)?;
45
- let validation = validate_attach_target(workspace, &state, target);
49
+ let mut receiver = receiver_for_attach_target(workspace, &state, &target.info, provider, Discovery::ExplicitPane)?;
50
+ if let Some(endpoint) = target.endpoint.as_ref() {
51
+ receiver.tmux_socket = Some(endpoint.clone());
52
+ }
53
+ let validation = validate_attach_target(workspace, &state, &target.info);
46
54
  if validation.is_err() {
47
- let pane_info = pane_info_value(target);
48
- let targets_value = Value::Array(targets.iter().map(pane_info_value).collect());
55
+ let pane_info = pane_info_value(&target.info);
56
+ let targets_value = Value::Array(targets.iter().map(|target| pane_info_value(&target.info)).collect());
49
57
  let owner_record = state_owner(&state);
50
58
  if let Some((readopted, validation)) = crate::leader::try_readopt_leader_pane(
51
59
  workspace,
@@ -85,7 +93,7 @@ pub fn attach_leader(
85
93
  let epoch = current_owner_epoch(&state);
86
94
  if state.get("team_owner").is_some() {
87
95
  write_receiver_to_state(&mut state, &receiver)?;
88
- write_lease_dual_state(workspace, &state)?;
96
+ write_claim_state(workspace, &state, scoped_team, None)?;
89
97
  event_log.write(
90
98
  super::LeaderEvent::ReceiverAttached.name(),
91
99
  json!({"pane_id": pane_id.as_str(), "owner_epoch": epoch.0}),
@@ -108,7 +116,7 @@ pub fn attach_leader(
108
116
  receiver.leader_session_uuid = Some(identity.leader_session_uuid.clone());
109
117
  let owner = make_owner(provider, &non_empty_pane_id, &identity, next_epoch);
110
118
  write_binding_to_state(&mut state, &receiver, &owner)?;
111
- write_lease_dual_state(workspace, &state)?;
119
+ write_claim_state(workspace, &state, scoped_team, None)?;
112
120
  event_log.write(
113
121
  super::LeaderEvent::ReceiverAttached.name(),
114
122
  json!({"pane_id": pane_id.as_str(), "owner_epoch": next_epoch.0}),
@@ -126,6 +134,80 @@ pub fn attach_leader(
126
134
  })
127
135
  }
128
136
 
137
+ #[derive(Clone)]
138
+ struct AttachLeaderTarget {
139
+ info: PaneInfo,
140
+ endpoint: Option<String>,
141
+ }
142
+
143
+ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTarget> {
144
+ let mut targets = Vec::new();
145
+ for endpoint in state_recorded_tmux_endpoints(state) {
146
+ let backend = tmux_backend_for_endpoint(&endpoint);
147
+ targets.extend(
148
+ backend
149
+ .list_targets()
150
+ .unwrap_or_default()
151
+ .into_iter()
152
+ .map(|info| AttachLeaderTarget {
153
+ info,
154
+ endpoint: Some(endpoint.clone()),
155
+ }),
156
+ );
157
+ }
158
+ targets.extend(
159
+ crate::tmux_backend::TmuxBackend::for_workspace(workspace)
160
+ .list_targets()
161
+ .unwrap_or_default()
162
+ .into_iter()
163
+ .map(|info| AttachLeaderTarget { info, endpoint: None }),
164
+ );
165
+ targets
166
+ }
167
+
168
+ fn tmux_backend_for_endpoint(endpoint: &str) -> crate::tmux_backend::TmuxBackend {
169
+ if endpoint.is_empty() || endpoint == "default" {
170
+ crate::tmux_backend::TmuxBackend::new()
171
+ } else if Path::new(endpoint).is_absolute() {
172
+ crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
173
+ } else {
174
+ crate::tmux_backend::TmuxBackend::for_socket_name(endpoint)
175
+ }
176
+ }
177
+
178
+ fn state_recorded_tmux_endpoints(state: &Value) -> BTreeSet<String> {
179
+ let mut out = BTreeSet::new();
180
+ push_state_tmux_endpoints(state, &mut out);
181
+ if let Some(teams) = state.get("teams").and_then(Value::as_object) {
182
+ for team_state in teams.values() {
183
+ push_state_tmux_endpoints(team_state, &mut out);
184
+ }
185
+ }
186
+ out
187
+ }
188
+
189
+ fn push_state_tmux_endpoints(state: &Value, out: &mut BTreeSet<String>) {
190
+ for key in ["tmux_socket", "tmux_endpoint"] {
191
+ if let Some(endpoint) = state
192
+ .get(key)
193
+ .and_then(Value::as_str)
194
+ .filter(|value| !value.is_empty())
195
+ {
196
+ out.insert(endpoint.to_string());
197
+ }
198
+ }
199
+ for key in ["team_owner", "leader_receiver"] {
200
+ if let Some(endpoint) = state
201
+ .get(key)
202
+ .and_then(|value| value.get("tmux_socket"))
203
+ .and_then(Value::as_str)
204
+ .filter(|value| !value.is_empty())
205
+ {
206
+ out.insert(endpoint.to_string());
207
+ }
208
+ }
209
+ }
210
+
129
211
  fn requeue_exhausted_watchers_after_attach(
130
212
  workspace: &Path,
131
213
  state: &Value,