@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/mod.rs +168 -75
- package/crates/team-agent/src/leader/start.rs +279 -4
- package/crates/team-agent/src/lifecycle/launch.rs +132 -23
- package/crates/team-agent/src/lifecycle/restart/common.rs +50 -40
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +125 -32
- package/crates/team-agent/src/lifecycle/restart/selection.rs +9 -6
- package/crates/team-agent/src/lifecycle/tests/core.rs +181 -45
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +267 -65
- package/crates/team-agent/src/tmux_backend.rs +5 -0
- package/crates/team-agent/src/transport.rs +4 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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
|
-
|
|
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
|
|
239
|
-
|
|
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
|
-
|
|
243
|
-
.
|
|
244
|
-
.
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
let
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
session
|
|
385
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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!(
|
|
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!(
|
|
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"]
|
|
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
|
-
|
|
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;
|