@team-agent/installer 0.3.13 → 0.3.15
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/adapters.rs +1 -0
- package/crates/team-agent/src/cli/emit.rs +92 -11
- package/crates/team-agent/src/cli/mod.rs +228 -85
- package/crates/team-agent/src/cli/send.rs +252 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +159 -2
- package/crates/team-agent/src/cli/types.rs +30 -1
- package/crates/team-agent/src/compiler/tests.rs +2 -2
- package/crates/team-agent/src/compiler.rs +1 -1
- package/crates/team-agent/src/fake_worker.rs +32 -145
- package/crates/team-agent/src/leader/start.rs +279 -4
- package/crates/team-agent/src/lifecycle/display.rs +3 -3
- package/crates/team-agent/src/lifecycle/launch.rs +692 -92
- package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
- package/crates/team-agent/src/lifecycle/restart/common.rs +88 -42
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +197 -33
- package/crates/team-agent/src/lifecycle/restart/selection.rs +9 -6
- package/crates/team-agent/src/lifecycle/tests/core.rs +195 -50
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +934 -72
- package/crates/team-agent/src/lifecycle/types.rs +5 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
- package/crates/team-agent/src/mcp_server/wire.rs +153 -3
- package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
- package/crates/team-agent/src/messaging/mod.rs +6 -3
- package/crates/team-agent/src/messaging/results.rs +240 -158
- package/crates/team-agent/src/messaging/send.rs +3 -2
- package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
- package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
- package/crates/team-agent/src/os_probe.rs +11 -0
- package/crates/team-agent/src/state/persist.rs +6 -0
- package/crates/team-agent/src/tmux_backend.rs +90 -0
- package/crates/team-agent/src/transport/test_support.rs +46 -1
- package/crates/team-agent/src/transport.rs +31 -0
- package/package.json +4 -4
- package/skills/team-agent/references/recovery-runbook.md +277 -0
|
@@ -115,9 +115,16 @@ pub mod lifecycle_port {
|
|
|
115
115
|
team_id: Option<&str>,
|
|
116
116
|
yes: bool,
|
|
117
117
|
fresh: bool,
|
|
118
|
+
open_display: bool,
|
|
118
119
|
) -> Result<Value, CliError> {
|
|
119
|
-
match crate::lifecycle::
|
|
120
|
-
workspace,
|
|
120
|
+
match crate::lifecycle::quick_start_in_workspace_with_display(
|
|
121
|
+
workspace,
|
|
122
|
+
agents_dir,
|
|
123
|
+
name,
|
|
124
|
+
yes,
|
|
125
|
+
fresh,
|
|
126
|
+
team_id,
|
|
127
|
+
open_display,
|
|
121
128
|
) {
|
|
122
129
|
Ok(report) => Ok(quick_start_value(report)),
|
|
123
130
|
Err(e) => Ok(error_value(e)),
|
|
@@ -168,48 +175,12 @@ pub mod lifecycle_port {
|
|
|
168
175
|
let run_ws = crate::model::paths::canonical_run_workspace(workspace)
|
|
169
176
|
.map_err(|e| CliError::Runtime(e.to_string()))?;
|
|
170
177
|
let state = shutdown_state_for_team(&run_ws, team)?;
|
|
171
|
-
let state_for_kill = state.clone();
|
|
172
178
|
let transport = if let Some(endpoint) = legacy_worker_tmux_endpoint(&state) {
|
|
173
|
-
|
|
179
|
+
shutdown_transport_for_endpoint(endpoint)
|
|
174
180
|
} else {
|
|
175
181
|
shutdown_workspace_transport(&run_ws)
|
|
176
182
|
};
|
|
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
|
|
183
|
+
shutdown_with_transport_and_state(workspace, keep_logs, team, &transport, Some(state))
|
|
213
184
|
}
|
|
214
185
|
|
|
215
186
|
/// E12 ①:从 state 锚 pane_id(leader_receiver/team_owner,top+teams)映射到其所在 session
|
|
@@ -235,15 +206,13 @@ pub mod lifecycle_port {
|
|
|
235
206
|
.collect()
|
|
236
207
|
}
|
|
237
208
|
|
|
238
|
-
fn
|
|
239
|
-
|
|
209
|
+
fn socket_session_names_from_targets(
|
|
210
|
+
pane_targets: &[crate::transport::PaneInfo],
|
|
240
211
|
) -> Vec<crate::transport::SessionName> {
|
|
241
212
|
let mut seen = std::collections::BTreeSet::new();
|
|
242
|
-
|
|
243
|
-
.
|
|
244
|
-
.
|
|
245
|
-
.into_iter()
|
|
246
|
-
.map(|pane| pane.session)
|
|
213
|
+
pane_targets
|
|
214
|
+
.iter()
|
|
215
|
+
.map(|pane| pane.session.clone())
|
|
247
216
|
.filter(|session| seen.insert(session.as_str().to_string()))
|
|
248
217
|
.collect()
|
|
249
218
|
}
|
|
@@ -281,6 +250,103 @@ pub mod lifecycle_port {
|
|
|
281
250
|
}
|
|
282
251
|
}
|
|
283
252
|
|
|
253
|
+
#[derive(Debug, Default)]
|
|
254
|
+
struct ShutdownSocketCleanup {
|
|
255
|
+
killed_sessions: Vec<crate::transport::SessionName>,
|
|
256
|
+
spared_sessions: Vec<crate::transport::SessionName>,
|
|
257
|
+
error: Option<String>,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
fn push_unique_session(
|
|
261
|
+
sessions: &mut Vec<crate::transport::SessionName>,
|
|
262
|
+
session: crate::transport::SessionName,
|
|
263
|
+
) {
|
|
264
|
+
if !sessions
|
|
265
|
+
.iter()
|
|
266
|
+
.any(|existing| existing.as_str() == session.as_str())
|
|
267
|
+
{
|
|
268
|
+
sessions.push(session);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fn bare_shutdown_socket_cleanup(
|
|
273
|
+
transport: &dyn crate::transport::Transport,
|
|
274
|
+
state: &Value,
|
|
275
|
+
event_log: &crate::event_log::EventLog,
|
|
276
|
+
) -> ShutdownSocketCleanup {
|
|
277
|
+
// E12 (P0): the leader terminal lives on this socket by design. A bare shutdown must
|
|
278
|
+
// NOT `kill-server` it away. spare = state-anchor sessions ∪ `team-agent-leader-*`
|
|
279
|
+
// prefix sessions (union; cr E12 ①). kill_server only when the socket is exclusively
|
|
280
|
+
// ours (no spare + no foreign session); shared socket → kill our sessions individually
|
|
281
|
+
// (cr E12 ②). All spare derivation comes from ONE snapshot (list_targets + state) —
|
|
282
|
+
// no independent ps/tmux re-derivation (N39).
|
|
283
|
+
let pane_targets = transport.list_targets().unwrap_or_default();
|
|
284
|
+
let sessions = socket_session_names_from_targets(&pane_targets);
|
|
285
|
+
let anchor_sessions = anchor_sessions_from_state(state, &pane_targets, event_log);
|
|
286
|
+
match sessions_to_kill(&sessions, &anchor_sessions) {
|
|
287
|
+
KillDecision::KillServerExclusive => {
|
|
288
|
+
if state
|
|
289
|
+
.get("tmux_socket_source")
|
|
290
|
+
.and_then(Value::as_str)
|
|
291
|
+
== Some("leader_env")
|
|
292
|
+
{
|
|
293
|
+
let _ = event_log.write(
|
|
294
|
+
"shutdown.kill_server_skipped_shared_socket",
|
|
295
|
+
json!({
|
|
296
|
+
"reason": "leader_env_tmux_socket",
|
|
297
|
+
"spared_sessions": [],
|
|
298
|
+
"killed_sessions": sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
let mut error = None;
|
|
302
|
+
for session in &sessions {
|
|
303
|
+
if let Err(err) = transport.kill_session(session) {
|
|
304
|
+
if !tmux_absent_error(&err.to_string()) {
|
|
305
|
+
error.get_or_insert_with(|| err.to_string());
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return ShutdownSocketCleanup {
|
|
310
|
+
killed_sessions: sessions,
|
|
311
|
+
spared_sessions: Vec::new(),
|
|
312
|
+
error,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
let error = transport.kill_server().err().map(|error| error.to_string());
|
|
316
|
+
ShutdownSocketCleanup {
|
|
317
|
+
killed_sessions: sessions,
|
|
318
|
+
spared_sessions: Vec::new(),
|
|
319
|
+
error,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
KillDecision::KillIndividually { to_kill, spared } => {
|
|
323
|
+
if !spared.is_empty() || to_kill.len() != sessions.len() {
|
|
324
|
+
// shared socket / leader spared → never whole-server teardown.
|
|
325
|
+
let _ = event_log.write(
|
|
326
|
+
"shutdown.kill_server_skipped_shared_socket",
|
|
327
|
+
json!({
|
|
328
|
+
"spared_sessions": spared.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
329
|
+
"killed_sessions": to_kill.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
let mut error = None;
|
|
334
|
+
for session in &to_kill {
|
|
335
|
+
if let Err(err) = transport.kill_session(session) {
|
|
336
|
+
if !tmux_absent_error(&err.to_string()) {
|
|
337
|
+
error.get_or_insert_with(|| err.to_string());
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
ShutdownSocketCleanup {
|
|
342
|
+
killed_sessions: to_kill,
|
|
343
|
+
spared_sessions: spared,
|
|
344
|
+
error,
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
284
350
|
pub fn shutdown_with_transport(
|
|
285
351
|
workspace: &Path,
|
|
286
352
|
keep_logs: bool,
|
|
@@ -358,8 +424,11 @@ pub mod lifecycle_port {
|
|
|
358
424
|
reap_process_tree(&root_pids, &protected, &entry_table);
|
|
359
425
|
reap_process_groups(&root_pgids, &protected);
|
|
360
426
|
let mut kill_error: Option<String> = None;
|
|
427
|
+
let mut killed_sessions = Vec::new();
|
|
428
|
+
let mut spared_sessions = Vec::new();
|
|
361
429
|
deadline.check("kill_session")?;
|
|
362
430
|
if let Some(session) = session_name.as_ref() {
|
|
431
|
+
push_unique_session(&mut killed_sessions, session.clone());
|
|
363
432
|
if let Err(error) = transport.kill_session(session) {
|
|
364
433
|
if !tmux_absent_error(&error.to_string()) {
|
|
365
434
|
kill_error = Some(error.to_string());
|
|
@@ -376,21 +445,28 @@ pub mod lifecycle_port {
|
|
|
376
445
|
reap_scope,
|
|
377
446
|
&mut probe_degraded,
|
|
378
447
|
);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
let
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
session
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if let Some(error) = error {
|
|
448
|
+
if team.is_none() {
|
|
449
|
+
deadline.check("shared_socket_cleanup")?;
|
|
450
|
+
let event_log = crate::event_log::EventLog::new(&run_workspace);
|
|
451
|
+
let cleanup = bare_shutdown_socket_cleanup(transport, &state, &event_log);
|
|
452
|
+
for session in cleanup.killed_sessions {
|
|
453
|
+
push_unique_session(&mut killed_sessions, session);
|
|
454
|
+
}
|
|
455
|
+
spared_sessions = cleanup.spared_sessions;
|
|
456
|
+
if let Some(error) = cleanup.error {
|
|
388
457
|
kill_error.get_or_insert(error);
|
|
389
458
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
459
|
+
}
|
|
460
|
+
deadline.check("session_residuals")?;
|
|
461
|
+
let (session_residuals, session_residual_error) = session_residuals_after_reap_many(
|
|
462
|
+
transport,
|
|
463
|
+
&run_workspace,
|
|
464
|
+
&killed_sessions,
|
|
465
|
+
!captured_missing_sessions,
|
|
466
|
+
);
|
|
467
|
+
if let Some(error) = session_residual_error {
|
|
468
|
+
kill_error.get_or_insert(error);
|
|
469
|
+
}
|
|
394
470
|
deadline.check("process_residuals")?;
|
|
395
471
|
// C-①: the post-verify gets ONE fresh verification snapshot (reaps changed
|
|
396
472
|
// the world; #248 post-verify facts must be current, not the entry view).
|
|
@@ -424,10 +500,18 @@ pub mod lifecycle_port {
|
|
|
424
500
|
None
|
|
425
501
|
};
|
|
426
502
|
let probe_timeout = crate::os_probe::probe_timeout();
|
|
427
|
-
|
|
428
|
-
//
|
|
429
|
-
|
|
430
|
-
|
|
503
|
+
let probe_timeout_kind = probe_timeout.as_ref().map(|timeout| timeout.probe);
|
|
504
|
+
// swallow batch 1: a failed ps probe degrades cleanup truthfully — the
|
|
505
|
+
// empty table must never read as a clean "no residual processes". A slow
|
|
506
|
+
// per-process cwd probe is diagnostic only once session/process residuals
|
|
507
|
+
// are otherwise clean.
|
|
508
|
+
let cleanup_truth_degraded = probe_degraded || probe_timeout_kind == Some("ps_table");
|
|
509
|
+
let diagnostic_probe_degraded = probe_timeout_kind == Some("lsof_cwd");
|
|
510
|
+
let other_probe_timeout_degraded =
|
|
511
|
+
probe_timeout.is_some() && !cleanup_truth_degraded && !diagnostic_probe_degraded;
|
|
512
|
+
let verification_degraded =
|
|
513
|
+
cleanup_truth_degraded || diagnostic_probe_degraded || other_probe_timeout_degraded;
|
|
514
|
+
let session_killed = !killed_sessions.is_empty()
|
|
431
515
|
&& kill_error.is_none()
|
|
432
516
|
&& session_residuals.is_empty()
|
|
433
517
|
&& process_residuals.is_empty();
|
|
@@ -458,13 +542,13 @@ pub mod lifecycle_port {
|
|
|
458
542
|
&& kill_error.is_none()
|
|
459
543
|
&& session_residuals.is_empty()
|
|
460
544
|
&& process_residuals.is_empty()
|
|
461
|
-
&& !
|
|
545
|
+
&& !cleanup_truth_degraded
|
|
462
546
|
&& !coordinator_timeout;
|
|
463
547
|
let status = if ok {
|
|
464
548
|
"ok"
|
|
465
549
|
} else if coordinator_timeout {
|
|
466
550
|
"timeout"
|
|
467
|
-
} else if
|
|
551
|
+
} else if cleanup_truth_degraded {
|
|
468
552
|
"partial"
|
|
469
553
|
} else if kill_error.is_some() {
|
|
470
554
|
"failed"
|
|
@@ -473,12 +557,11 @@ pub mod lifecycle_port {
|
|
|
473
557
|
};
|
|
474
558
|
let phase = if coordinator_timeout {
|
|
475
559
|
Some("stop_coordinator")
|
|
476
|
-
} else if
|
|
560
|
+
} else if cleanup_truth_degraded {
|
|
477
561
|
Some("os_probe")
|
|
478
562
|
} else {
|
|
479
563
|
None
|
|
480
564
|
};
|
|
481
|
-
let probe_timeout_kind = probe_timeout.as_ref().map(|timeout| timeout.probe);
|
|
482
565
|
let probe_timeout_value = probe_timeout.as_ref().map(|timeout| {
|
|
483
566
|
json!({
|
|
484
567
|
"probe": timeout.probe,
|
|
@@ -494,6 +577,8 @@ pub mod lifecycle_port {
|
|
|
494
577
|
"team": team,
|
|
495
578
|
"session_name": session_name.as_ref().map(|s| s.as_str().to_string()),
|
|
496
579
|
"session_killed": session_killed,
|
|
580
|
+
"killed_sessions": killed_sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
581
|
+
"spared_sessions": spared_sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
497
582
|
"coordinator_status": coordinator_status,
|
|
498
583
|
"status": status,
|
|
499
584
|
"phase": phase,
|
|
@@ -515,6 +600,8 @@ pub mod lifecycle_port {
|
|
|
515
600
|
"team": team,
|
|
516
601
|
"session_name": session_name.map(|s| s.as_str().to_string()),
|
|
517
602
|
"session_killed": session_killed,
|
|
603
|
+
"killed_sessions": killed_sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
604
|
+
"spared_sessions": spared_sessions.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
|
|
518
605
|
"residuals": {
|
|
519
606
|
"sessions": session_residuals,
|
|
520
607
|
"processes": process_residuals,
|
|
@@ -606,10 +693,19 @@ pub mod lifecycle_port {
|
|
|
606
693
|
crate::tmux_backend::TmuxBackend::for_workspace(workspace)
|
|
607
694
|
}
|
|
608
695
|
|
|
696
|
+
fn shutdown_transport_for_endpoint(endpoint: &str) -> crate::tmux_backend::TmuxBackend {
|
|
697
|
+
if Path::new(endpoint).is_absolute() {
|
|
698
|
+
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
|
|
699
|
+
} else {
|
|
700
|
+
crate::tmux_backend::TmuxBackend::for_socket_name(endpoint)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
609
704
|
fn legacy_worker_tmux_endpoint(state: &Value) -> Option<&str> {
|
|
610
705
|
state
|
|
611
706
|
.get("tmux_endpoint")
|
|
612
707
|
.and_then(Value::as_str)
|
|
708
|
+
.or_else(|| state.get("tmux_socket").and_then(Value::as_str))
|
|
613
709
|
.filter(|endpoint| !endpoint.is_empty())
|
|
614
710
|
}
|
|
615
711
|
|
|
@@ -673,6 +769,33 @@ pub mod lifecycle_port {
|
|
|
673
769
|
(sessions, error)
|
|
674
770
|
}
|
|
675
771
|
|
|
772
|
+
fn session_residuals_after_reap_many(
|
|
773
|
+
transport: &dyn crate::transport::Transport,
|
|
774
|
+
workspace: &Path,
|
|
775
|
+
sessions: &[crate::transport::SessionName],
|
|
776
|
+
check_primary_transport: bool,
|
|
777
|
+
) -> (Vec<String>, Option<String>) {
|
|
778
|
+
let mut residuals = Vec::new();
|
|
779
|
+
let mut error = None;
|
|
780
|
+
for session in sessions {
|
|
781
|
+
let (session_residuals, session_error) = session_residuals_after_reap(
|
|
782
|
+
transport,
|
|
783
|
+
workspace,
|
|
784
|
+
session,
|
|
785
|
+
check_primary_transport,
|
|
786
|
+
);
|
|
787
|
+
for residual in session_residuals {
|
|
788
|
+
if !residuals.iter().any(|seen| seen == &residual) {
|
|
789
|
+
residuals.push(residual);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if let Some(session_error) = session_error {
|
|
793
|
+
error.get_or_insert(session_error);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
(residuals, error)
|
|
797
|
+
}
|
|
798
|
+
|
|
676
799
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
677
800
|
enum ShutdownReapScope {
|
|
678
801
|
Workspace,
|
|
@@ -721,11 +844,7 @@ pub mod lifecycle_port {
|
|
|
721
844
|
/// pids; Gap 37 escalation order TERM -> grace -> KILL preserved), then a single
|
|
722
845
|
/// bounded wait for the whole union. kill/wait sets derive from the SAME snapshot
|
|
723
846
|
/// as the protected set (N39).
|
|
724
|
-
fn reap_process_tree(
|
|
725
|
-
root_pids: &[u32],
|
|
726
|
-
protected: &ShutdownProtection,
|
|
727
|
-
table: &[ProcessInfo],
|
|
728
|
-
) {
|
|
847
|
+
fn reap_process_tree(root_pids: &[u32], protected: &ShutdownProtection, table: &[ProcessInfo]) {
|
|
729
848
|
let mut pids = Vec::new();
|
|
730
849
|
let mut seen = std::collections::BTreeSet::new();
|
|
731
850
|
for root in root_pids {
|
|
@@ -787,12 +906,21 @@ pub mod lifecycle_port {
|
|
|
787
906
|
let mut protected = shutdown_protection_set(&round_table);
|
|
788
907
|
extend_protection_with_leader_panes(&mut protected, transport, state, &round_table);
|
|
789
908
|
let residuals = matched_processes(
|
|
790
|
-
workspace,
|
|
909
|
+
workspace,
|
|
910
|
+
state,
|
|
911
|
+
root_pids,
|
|
912
|
+
root_pgids,
|
|
913
|
+
&protected,
|
|
914
|
+
scope,
|
|
915
|
+
&round_table,
|
|
791
916
|
);
|
|
792
917
|
if residuals.is_empty() {
|
|
793
918
|
return;
|
|
794
919
|
}
|
|
795
|
-
let residual_pids = residuals
|
|
920
|
+
let residual_pids = residuals
|
|
921
|
+
.iter()
|
|
922
|
+
.map(|process| process.pid)
|
|
923
|
+
.collect::<Vec<_>>();
|
|
796
924
|
reap_process_tree(&residual_pids, &protected, &round_table);
|
|
797
925
|
let pgids = residuals
|
|
798
926
|
.iter()
|
|
@@ -906,7 +1034,9 @@ pub mod lifecycle_port {
|
|
|
906
1034
|
/// - E4b team-in-team:子 team state 的 team_owner.pane_id 指父 team worker pane;
|
|
907
1035
|
/// 父 team state 的 teams.<child>.team_owner.pane_id 同义(若有该字段)
|
|
908
1036
|
/// → 任一 team 的 shutdown 都不杀任何 team 的 leader 锚 pane
|
|
909
|
-
pub fn collect_state_leader_anchor_pane_ids(
|
|
1037
|
+
pub fn collect_state_leader_anchor_pane_ids(
|
|
1038
|
+
state: &Value,
|
|
1039
|
+
) -> std::collections::BTreeSet<String> {
|
|
910
1040
|
let mut out = std::collections::BTreeSet::new();
|
|
911
1041
|
push_anchor_pane_id(state, &mut out);
|
|
912
1042
|
if let Some(teams) = state.get("teams").and_then(Value::as_object) {
|
|
@@ -1042,7 +1172,10 @@ pub mod lifecycle_port {
|
|
|
1042
1172
|
for socket_endpoint in collect_state_recorded_tmux_sockets(state) {
|
|
1043
1173
|
let cross_backend =
|
|
1044
1174
|
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&socket_endpoint);
|
|
1045
|
-
let cross_panes =
|
|
1175
|
+
let cross_panes =
|
|
1176
|
+
<crate::tmux_backend::TmuxBackend as crate::transport::Transport>::list_targets(
|
|
1177
|
+
&cross_backend,
|
|
1178
|
+
)
|
|
1046
1179
|
.unwrap_or_default();
|
|
1047
1180
|
leader_pane_pids.extend(
|
|
1048
1181
|
cross_panes
|
|
@@ -1167,8 +1300,9 @@ pub mod lifecycle_port {
|
|
|
1167
1300
|
scope: ShutdownReapScope,
|
|
1168
1301
|
table: &[ProcessInfo],
|
|
1169
1302
|
) -> Vec<Value> {
|
|
1170
|
-
let mut residuals =
|
|
1171
|
-
|
|
1303
|
+
let mut residuals = matched_processes(
|
|
1304
|
+
workspace, state, root_pids, root_pgids, protected, scope, table,
|
|
1305
|
+
);
|
|
1172
1306
|
let mut seen = residuals
|
|
1173
1307
|
.iter()
|
|
1174
1308
|
.map(|process| process.pid)
|
|
@@ -1891,7 +2025,7 @@ pub mod lifecycle_port {
|
|
|
1891
2025
|
state_path: Some(PathBuf::from("/tmp/state.json")),
|
|
1892
2026
|
next_actions: vec!["restart".to_string()],
|
|
1893
2027
|
attach_commands: vec![
|
|
1894
|
-
"tmux -S /tmp/tmux-501/ta-test attach -t team-teamA:worker".to_string()
|
|
2028
|
+
"tmux -S /tmp/tmux-501/ta-test attach -t team-teamA:worker".to_string()
|
|
1895
2029
|
],
|
|
1896
2030
|
});
|
|
1897
2031
|
assert_eq!(
|
|
@@ -2157,7 +2291,10 @@ pub mod lifecycle_port {
|
|
|
2157
2291
|
let msg = "agent start requirement unmet: agent foo not found";
|
|
2158
2292
|
let na = error_next_action(msg).expect("not-found must carry next_action");
|
|
2159
2293
|
assert!(na.contains("add-agent"), "must steer to add-agent: {na}");
|
|
2160
|
-
assert!(
|
|
2294
|
+
assert!(
|
|
2295
|
+
na.contains("--role-file"),
|
|
2296
|
+
"must show the role-file flag: {na}"
|
|
2297
|
+
);
|
|
2161
2298
|
}
|
|
2162
2299
|
|
|
2163
2300
|
#[test]
|
|
@@ -2176,7 +2313,10 @@ pub mod lifecycle_port {
|
|
|
2176
2313
|
|
|
2177
2314
|
#[test]
|
|
2178
2315
|
fn unrelated_error_has_no_next_action() {
|
|
2179
|
-
assert_eq!(
|
|
2316
|
+
assert_eq!(
|
|
2317
|
+
error_next_action("state persistence failed: disk full"),
|
|
2318
|
+
None
|
|
2319
|
+
);
|
|
2180
2320
|
}
|
|
2181
2321
|
|
|
2182
2322
|
#[test]
|
|
@@ -2187,7 +2327,10 @@ pub mod lifecycle_port {
|
|
|
2187
2327
|
let v = error_value(err);
|
|
2188
2328
|
assert_eq!(v["ok"], serde_json::json!(false));
|
|
2189
2329
|
assert!(
|
|
2190
|
-
v["next_action"]
|
|
2330
|
+
v["next_action"]
|
|
2331
|
+
.as_str()
|
|
2332
|
+
.unwrap_or("")
|
|
2333
|
+
.contains("add-agent"),
|
|
2191
2334
|
"error_value must attach the add-agent guidance: {v}"
|
|
2192
2335
|
);
|
|
2193
2336
|
}
|
|
@@ -2242,8 +2385,8 @@ pub mod diagnose_port {
|
|
|
2242
2385
|
.unwrap_or(true);
|
|
2243
2386
|
// legacy 降级面(legacy_team_invalid)不下拉整体 ok —— 用户没显式让我们
|
|
2244
2387
|
// 体检这个 team,失败是降级诊断信息,不是 install 自检失败。
|
|
2245
|
-
let legacy_only_failure =
|
|
2246
|
-
|
|
2388
|
+
let legacy_only_failure = !profile_smoke_ok
|
|
2389
|
+
&& profile_smoke_value.get("status").and_then(Value::as_str)
|
|
2247
2390
|
== Some("legacy_team_invalid");
|
|
2248
2391
|
let effective_smoke_ok = profile_smoke_ok || legacy_only_failure;
|
|
2249
2392
|
let ok = workspace_valid && (team_context || workspace_has_entries) && effective_smoke_ok;
|