@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/leader.rs +2 -0
- package/crates/team-agent/src/cli/mod.rs +199 -31
- package/crates/team-agent/src/cli/status.rs +5 -1
- package/crates/team-agent/src/cli/status_port.rs +24 -0
- package/crates/team-agent/src/cli/tests/base.rs +31 -3
- package/crates/team-agent/src/cli/tests/divergence.rs +6 -6
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +127 -1
- package/crates/team-agent/src/cli/tests/status_send.rs +25 -0
- package/crates/team-agent/src/cli/types.rs +2 -0
- package/crates/team-agent/src/leader/lease.rs +93 -11
- package/crates/team-agent/src/leader/start.rs +310 -8
- package/crates/team-agent/src/leader/tests/basics.rs +1 -0
- package/crates/team-agent/src/leader/tests/identity.rs +121 -25
- package/crates/team-agent/src/leader/tests/lease_api.rs +2 -2
- package/crates/team-agent/src/leader/types.rs +9 -0
- package/crates/team-agent/src/lifecycle/launch.rs +72 -2
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +7 -3
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +63 -0
- package/package.json +4 -4
|
@@ -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
|
|
33
|
-
let
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|