@team-agent/installer 0.5.92 → 0.5.94
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 +370 -441
- package/crates/team-agent/src/cli/models.rs +1 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +154 -30
- package/crates/team-agent/src/coordinator/health.rs +92 -30
- package/crates/team-agent/src/coordinator/tick.rs +62 -8
- package/crates/team-agent/src/coordinator/types.rs +2 -0
- package/crates/team-agent/src/kill_audit.rs +41 -2
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +48 -14
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +4 -8
- package/crates/team-agent/src/lifecycle/launch.rs +17 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +10 -7
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +91 -4
- package/crates/team-agent/src/platform/process.rs +4 -45
- package/crates/team-agent/src/tmux_backend.rs +113 -3
- package/crates/team-agent/src/transport/test_support.rs +30 -0
- package/crates/team-agent/src/transport.rs +41 -0
- package/package.json +4 -4
|
@@ -902,6 +902,7 @@ mod tests {
|
|
|
902
902
|
|
|
903
903
|
#[cfg(unix)]
|
|
904
904
|
#[test]
|
|
905
|
+
#[serial_test::serial(env)]
|
|
905
906
|
fn command_boundary_connects_runner_parser_search_and_contract() {
|
|
906
907
|
let path = fixture(
|
|
907
908
|
"printf 'provider model\\nopenai-codex gpt-5.6-sol\\nopenai-codex gpt-5.6-luna\\n'",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
//! E12 (P0) · `sessions_to_kill` 纯决策单测(kill 决策下沉)。
|
|
2
2
|
//!
|
|
3
3
|
//! spare = state 锚 session(anchor_sessions) ∪ `team-agent-leader-` 命名前缀(并集,锚优先)。
|
|
4
|
-
//!
|
|
4
|
+
//! Cleanup is always per-session and ownership-gated; a socket is never sufficient authority for kill-server.
|
|
5
5
|
//! 集成面由 tests/b5_leader_terminal_kill_red.rs 的真 tmux 契约覆盖,此处锁纯决策 + 4 反向 case。
|
|
6
6
|
|
|
7
7
|
use crate::cli::lifecycle_port::{sessions_to_kill, KillDecision};
|
|
@@ -23,12 +23,15 @@ fn anchors(raw: &[&str]) -> BTreeSet<String> {
|
|
|
23
23
|
raw.iter().map(|s| s.to_string()).collect()
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
// RC-4
|
|
26
|
+
// RC-4: even an apparently exclusive socket uses per-session cleanup.
|
|
27
27
|
#[test]
|
|
28
|
-
fn
|
|
28
|
+
fn rc4_exclusive_socket_is_fail_closed() {
|
|
29
29
|
assert_eq!(
|
|
30
30
|
sessions_to_kill(&names(&["team-x", "team-y"]), &BTreeSet::new()),
|
|
31
|
-
KillDecision::
|
|
31
|
+
KillDecision::KillIndividually {
|
|
32
|
+
to_kill: names(&["team-x", "team-y"]),
|
|
33
|
+
spared: vec![]
|
|
34
|
+
}
|
|
32
35
|
);
|
|
33
36
|
// 空 session 集 → 逐 kill(no-op),不整 server 拆(没东西可拆)。
|
|
34
37
|
assert_eq!(
|
|
@@ -87,10 +90,13 @@ fn rc2_no_anchor_falls_back_to_naming() {
|
|
|
87
90
|
&BTreeSet::new(),
|
|
88
91
|
);
|
|
89
92
|
assert!(matches!(with_leader, KillDecision::KillIndividually { .. }));
|
|
90
|
-
// 无锚 +
|
|
93
|
+
// 无锚 + 无前缀仍只能逐 session 返回,实际 shutdown 会因 marker 缺失拒绝杀灭。
|
|
91
94
|
assert_eq!(
|
|
92
95
|
sessions_to_kill(&names(&["team-x"]), &BTreeSet::new()),
|
|
93
|
-
KillDecision::
|
|
96
|
+
KillDecision::KillIndividually {
|
|
97
|
+
to_kill: names(&["team-x"]),
|
|
98
|
+
spared: vec![]
|
|
99
|
+
}
|
|
94
100
|
);
|
|
95
101
|
}
|
|
96
102
|
|
|
@@ -169,6 +175,8 @@ fn owned_empty_endpoint_cleanup_removes_socket_file_before_reporting_ok() {
|
|
|
169
175
|
&ws,
|
|
170
176
|
&json!({
|
|
171
177
|
"session_name": "team-owned-clean",
|
|
178
|
+
"team_key": "team-owned-clean",
|
|
179
|
+
"generation": "team-owned-clean",
|
|
172
180
|
"tmux_endpoint": socket.to_string_lossy(),
|
|
173
181
|
"tmux_socket": socket.to_string_lossy(),
|
|
174
182
|
"tmux_socket_source": "workspace",
|
|
@@ -177,7 +185,12 @@ fn owned_empty_endpoint_cleanup_removes_socket_file_before_reporting_ok() {
|
|
|
177
185
|
}),
|
|
178
186
|
)
|
|
179
187
|
.unwrap();
|
|
180
|
-
let transport = CleanShutdownTransport::new()
|
|
188
|
+
let transport = CleanShutdownTransport::new().with_owned_session(
|
|
189
|
+
&ws,
|
|
190
|
+
"team-owned-clean",
|
|
191
|
+
"team-owned-clean",
|
|
192
|
+
"team-owned-clean",
|
|
193
|
+
);
|
|
181
194
|
|
|
182
195
|
let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
|
|
183
196
|
.expect("shutdown should complete");
|
|
@@ -186,12 +199,12 @@ fn owned_empty_endpoint_cleanup_removes_socket_file_before_reporting_ok() {
|
|
|
186
199
|
assert_eq!(out["status"], json!("ok"));
|
|
187
200
|
assert_eq!(out["residuals"]["owned_files"], json!([]));
|
|
188
201
|
assert!(
|
|
189
|
-
|
|
190
|
-
"
|
|
202
|
+
socket.exists(),
|
|
203
|
+
"fail-closed endpoint cleanup must preserve the socket file"
|
|
191
204
|
);
|
|
192
205
|
assert!(
|
|
193
|
-
transport.kill_server_called(),
|
|
194
|
-
"
|
|
206
|
+
!transport.kill_server_called(),
|
|
207
|
+
"endpoint ownership must not authorize server teardown"
|
|
195
208
|
);
|
|
196
209
|
}
|
|
197
210
|
|
|
@@ -235,6 +248,8 @@ fn owned_file_residual_makes_shutdown_failed_not_partial() {
|
|
|
235
248
|
&ws,
|
|
236
249
|
&json!({
|
|
237
250
|
"session_name": "team-owned-residual",
|
|
251
|
+
"team_key": "team-owned-residual",
|
|
252
|
+
"generation": "team-owned-residual",
|
|
238
253
|
"tmux_endpoint": socket_dir.to_string_lossy(),
|
|
239
254
|
"tmux_socket": socket_dir.to_string_lossy(),
|
|
240
255
|
"tmux_socket_source": "workspace",
|
|
@@ -243,18 +258,22 @@ fn owned_file_residual_makes_shutdown_failed_not_partial() {
|
|
|
243
258
|
}),
|
|
244
259
|
)
|
|
245
260
|
.unwrap();
|
|
246
|
-
let transport = CleanShutdownTransport::new()
|
|
261
|
+
let transport = CleanShutdownTransport::new().with_owned_session(
|
|
262
|
+
&ws,
|
|
263
|
+
"team-owned-residual",
|
|
264
|
+
"team-owned-residual",
|
|
265
|
+
"team-owned-residual",
|
|
266
|
+
);
|
|
247
267
|
|
|
248
268
|
let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
|
|
249
269
|
.expect("shutdown should complete");
|
|
250
270
|
|
|
251
|
-
assert_eq!(out["ok"], json!(
|
|
252
|
-
assert_eq!(out["status"], json!("
|
|
271
|
+
assert_eq!(out["ok"], json!(true));
|
|
272
|
+
assert_eq!(out["status"], json!("ok"));
|
|
253
273
|
assert_eq!(out["phase"], json!(null));
|
|
254
|
-
assert_eq!(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
);
|
|
274
|
+
assert_eq!(out["residuals"]["owned_files"], json!([]));
|
|
275
|
+
assert!(socket_dir.exists(), "fail-closed cleanup preserves residual files");
|
|
276
|
+
assert!(!transport.kill_server_called());
|
|
258
277
|
}
|
|
259
278
|
|
|
260
279
|
#[test]
|
|
@@ -340,6 +359,7 @@ fn scoped_clean_shutdown_persists_team_shutdown_status_after_disk_roundtrip() {
|
|
|
340
359
|
"schema_version": 1,
|
|
341
360
|
"active_team_key": "current",
|
|
342
361
|
"team_key": "current",
|
|
362
|
+
"generation": "current",
|
|
343
363
|
"session_name": "team-current",
|
|
344
364
|
"team_dir": ws.display().to_string(),
|
|
345
365
|
"workspace": ws.display().to_string(),
|
|
@@ -354,6 +374,7 @@ fn scoped_clean_shutdown_persists_team_shutdown_status_after_disk_roundtrip() {
|
|
|
354
374
|
"teams": {
|
|
355
375
|
"current": {
|
|
356
376
|
"team_key": "current",
|
|
377
|
+
"generation": "current",
|
|
357
378
|
"session_name": "team-current",
|
|
358
379
|
"team_dir": ws.display().to_string(),
|
|
359
380
|
"workspace": ws.display().to_string(),
|
|
@@ -375,7 +396,12 @@ fn scoped_clean_shutdown_persists_team_shutdown_status_after_disk_roundtrip() {
|
|
|
375
396
|
&ws,
|
|
376
397
|
true,
|
|
377
398
|
Some("current"),
|
|
378
|
-
&CleanShutdownTransport::new()
|
|
399
|
+
&CleanShutdownTransport::new().with_owned_session(
|
|
400
|
+
&ws,
|
|
401
|
+
"team-current",
|
|
402
|
+
"current",
|
|
403
|
+
"current",
|
|
404
|
+
),
|
|
379
405
|
)
|
|
380
406
|
.expect("shutdown should complete");
|
|
381
407
|
|
|
@@ -414,6 +440,7 @@ fn scoped_degraded_shutdown_does_not_persist_team_shutdown_status() {
|
|
|
414
440
|
"schema_version": 1,
|
|
415
441
|
"active_team_key": "current",
|
|
416
442
|
"team_key": "current",
|
|
443
|
+
"generation": "current",
|
|
417
444
|
"session_name": "team-current",
|
|
418
445
|
"agents": {
|
|
419
446
|
"adminweb": {
|
|
@@ -426,6 +453,7 @@ fn scoped_degraded_shutdown_does_not_persist_team_shutdown_status() {
|
|
|
426
453
|
"teams": {
|
|
427
454
|
"current": {
|
|
428
455
|
"team_key": "current",
|
|
456
|
+
"generation": "current",
|
|
429
457
|
"session_name": "team-current",
|
|
430
458
|
"agents": {
|
|
431
459
|
"adminweb": {
|
|
@@ -445,7 +473,9 @@ fn scoped_degraded_shutdown_does_not_persist_team_shutdown_status() {
|
|
|
445
473
|
&ws,
|
|
446
474
|
true,
|
|
447
475
|
Some("current"),
|
|
448
|
-
&CleanShutdownTransport::new()
|
|
476
|
+
&CleanShutdownTransport::new()
|
|
477
|
+
.with_owned_session(&ws, "team-current", "current", "current")
|
|
478
|
+
.with_probe_timeout("ps_table"),
|
|
449
479
|
)
|
|
450
480
|
.expect("shutdown should complete");
|
|
451
481
|
|
|
@@ -519,6 +549,8 @@ fn repeated_owned_endpoint_shutdowns_leave_no_socket_file_growth() {
|
|
|
519
549
|
&ws,
|
|
520
550
|
&json!({
|
|
521
551
|
"session_name": "team-owned-loop",
|
|
552
|
+
"team_key": "team-owned-loop",
|
|
553
|
+
"generation": "team-owned-loop",
|
|
522
554
|
"tmux_endpoint": socket.to_string_lossy(),
|
|
523
555
|
"tmux_socket": socket.to_string_lossy(),
|
|
524
556
|
"tmux_socket_source": "workspace",
|
|
@@ -535,7 +567,12 @@ fn repeated_owned_endpoint_shutdowns_leave_no_socket_file_growth() {
|
|
|
535
567
|
&ws,
|
|
536
568
|
true,
|
|
537
569
|
None,
|
|
538
|
-
&CleanShutdownTransport::new()
|
|
570
|
+
&CleanShutdownTransport::new().with_owned_session(
|
|
571
|
+
&ws,
|
|
572
|
+
"team-owned-loop",
|
|
573
|
+
"team-owned-loop",
|
|
574
|
+
"team-owned-loop",
|
|
575
|
+
),
|
|
539
576
|
);
|
|
540
577
|
let out = match &result {
|
|
541
578
|
Ok(out) if !shutdown_result_is_degraded(out) => out,
|
|
@@ -573,8 +610,9 @@ fn repeated_owned_endpoint_shutdowns_leave_no_socket_file_growth() {
|
|
|
573
610
|
}
|
|
574
611
|
let ending = sockets.iter().filter(|path| path.exists()).count();
|
|
575
612
|
assert_eq!(
|
|
576
|
-
ending,
|
|
577
|
-
|
|
613
|
+
ending,
|
|
614
|
+
starting + sockets.len(),
|
|
615
|
+
"fail-closed endpoint cleanup must not remove unverified socket files"
|
|
578
616
|
);
|
|
579
617
|
#[cfg(unix)]
|
|
580
618
|
if expected_probe_failure {
|
|
@@ -793,6 +831,7 @@ struct CleanShutdownTransport {
|
|
|
793
831
|
probe_timeout_kind: Option<&'static str>,
|
|
794
832
|
targets_persist_after_kill: bool,
|
|
795
833
|
kill_session_error: Option<String>,
|
|
834
|
+
owner: Option<crate::transport::SessionOwner>,
|
|
796
835
|
// E49 (0.3.24 P0, shutdown kills leader CLI): record per-pane / per-window /
|
|
797
836
|
// per-session kill targets so RED contracts can assert (a) the leader pane is
|
|
798
837
|
// never killed and (b) worker panes ARE killed via the new per-pane path.
|
|
@@ -810,6 +849,7 @@ impl CleanShutdownTransport {
|
|
|
810
849
|
probe_timeout_kind: None,
|
|
811
850
|
targets_persist_after_kill: false,
|
|
812
851
|
kill_session_error: None,
|
|
852
|
+
owner: None,
|
|
813
853
|
killed_panes: Mutex::new(Vec::new()),
|
|
814
854
|
killed_window_targets: Mutex::new(Vec::new()),
|
|
815
855
|
killed_sessions: Mutex::new(Vec::new()),
|
|
@@ -821,6 +861,46 @@ impl CleanShutdownTransport {
|
|
|
821
861
|
self
|
|
822
862
|
}
|
|
823
863
|
|
|
864
|
+
fn with_owned_session(
|
|
865
|
+
mut self,
|
|
866
|
+
workspace: &Path,
|
|
867
|
+
session: &str,
|
|
868
|
+
team: &str,
|
|
869
|
+
generation: &str,
|
|
870
|
+
) -> Self {
|
|
871
|
+
self.owner = Some(crate::transport::SessionOwner {
|
|
872
|
+
workspace: workspace
|
|
873
|
+
.canonicalize()
|
|
874
|
+
.unwrap_or_else(|_| workspace.to_path_buf())
|
|
875
|
+
.to_string_lossy()
|
|
876
|
+
.into_owned(),
|
|
877
|
+
team: team.to_string(),
|
|
878
|
+
generation: generation.to_string(),
|
|
879
|
+
});
|
|
880
|
+
if self.targets.is_empty() {
|
|
881
|
+
self.targets.push(PaneInfo {
|
|
882
|
+
pane_id: PaneId::new("%owned"),
|
|
883
|
+
session: SessionName::new(session),
|
|
884
|
+
window_index: Some(0),
|
|
885
|
+
window_name: Some(WindowName::new("owned")),
|
|
886
|
+
pane_index: Some(0),
|
|
887
|
+
tty: None,
|
|
888
|
+
current_command: Some("codex".to_string()),
|
|
889
|
+
current_path: Some(workspace.to_path_buf()),
|
|
890
|
+
active: true,
|
|
891
|
+
pane_pid: None,
|
|
892
|
+
leader_env: BTreeMap::new(),
|
|
893
|
+
});
|
|
894
|
+
} else {
|
|
895
|
+
for target in &mut self.targets {
|
|
896
|
+
if target.current_path.is_none() {
|
|
897
|
+
target.current_path = Some(workspace.to_path_buf());
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
self
|
|
902
|
+
}
|
|
903
|
+
|
|
824
904
|
fn kill_server_called(&self) -> bool {
|
|
825
905
|
*self.kill_server_called.lock().unwrap()
|
|
826
906
|
}
|
|
@@ -931,6 +1011,13 @@ impl Transport for CleanShutdownTransport {
|
|
|
931
1011
|
Ok(*self.session_present.lock().unwrap())
|
|
932
1012
|
}
|
|
933
1013
|
|
|
1014
|
+
fn session_owner(
|
|
1015
|
+
&self,
|
|
1016
|
+
_session: &SessionName,
|
|
1017
|
+
) -> Result<Option<crate::transport::SessionOwner>, TransportError> {
|
|
1018
|
+
Ok(self.owner.clone())
|
|
1019
|
+
}
|
|
1020
|
+
|
|
934
1021
|
fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
|
|
935
1022
|
Ok(Vec::new())
|
|
936
1023
|
}
|
|
@@ -1001,6 +1088,8 @@ fn lsof_cwd_timeout_is_diagnostic_not_shutdown_partial() {
|
|
|
1001
1088
|
&ws,
|
|
1002
1089
|
&json!({
|
|
1003
1090
|
"session_name": "team-lsof-cwd-timeout",
|
|
1091
|
+
"team_key": "team-lsof-cwd-timeout",
|
|
1092
|
+
"generation": "team-lsof-cwd-timeout",
|
|
1004
1093
|
"is_external_leader": true,
|
|
1005
1094
|
"agents": {
|
|
1006
1095
|
"fake_impl": {
|
|
@@ -1017,7 +1106,14 @@ fn lsof_cwd_timeout_is_diagnostic_not_shutdown_partial() {
|
|
|
1017
1106
|
&ws,
|
|
1018
1107
|
true,
|
|
1019
1108
|
None,
|
|
1020
|
-
&CleanShutdownTransport::new()
|
|
1109
|
+
&CleanShutdownTransport::new()
|
|
1110
|
+
.with_owned_session(
|
|
1111
|
+
&ws,
|
|
1112
|
+
"team-lsof-cwd-timeout",
|
|
1113
|
+
"team-lsof-cwd-timeout",
|
|
1114
|
+
"team-lsof-cwd-timeout",
|
|
1115
|
+
)
|
|
1116
|
+
.with_probe_timeout("lsof_cwd"),
|
|
1021
1117
|
)
|
|
1022
1118
|
.expect("shutdown should complete");
|
|
1023
1119
|
|
|
@@ -1051,6 +1147,8 @@ fn ps_table_timeout_still_degrades_shutdown_truth() {
|
|
|
1051
1147
|
&ws,
|
|
1052
1148
|
&json!({
|
|
1053
1149
|
"session_name": "team-ps-table-timeout",
|
|
1150
|
+
"team_key": "team-ps-table-timeout",
|
|
1151
|
+
"generation": "team-ps-table-timeout",
|
|
1054
1152
|
"agents": {
|
|
1055
1153
|
"fake_impl": {
|
|
1056
1154
|
"status": "running",
|
|
@@ -1066,7 +1164,14 @@ fn ps_table_timeout_still_degrades_shutdown_truth() {
|
|
|
1066
1164
|
&ws,
|
|
1067
1165
|
true,
|
|
1068
1166
|
None,
|
|
1069
|
-
&CleanShutdownTransport::new()
|
|
1167
|
+
&CleanShutdownTransport::new()
|
|
1168
|
+
.with_owned_session(
|
|
1169
|
+
&ws,
|
|
1170
|
+
"team-ps-table-timeout",
|
|
1171
|
+
"team-ps-table-timeout",
|
|
1172
|
+
"team-ps-table-timeout",
|
|
1173
|
+
)
|
|
1174
|
+
.with_probe_timeout("ps_table"),
|
|
1070
1175
|
)
|
|
1071
1176
|
.expect("shutdown should complete");
|
|
1072
1177
|
|
|
@@ -1169,6 +1274,9 @@ fn leader_env_tmux_socket_never_kills_server_even_when_sessions_look_exclusive()
|
|
|
1169
1274
|
&ws,
|
|
1170
1275
|
&json!({
|
|
1171
1276
|
"is_external_leader": true,
|
|
1277
|
+
"team_key": "team-layout",
|
|
1278
|
+
"generation": "team-layout",
|
|
1279
|
+
"session_name": "team-layout",
|
|
1172
1280
|
"tmux_socket_source": "leader_env",
|
|
1173
1281
|
"agents": {
|
|
1174
1282
|
"fake_impl": {
|
|
@@ -1193,6 +1301,7 @@ fn leader_env_tmux_socket_never_kills_server_even_when_sessions_look_exclusive()
|
|
|
1193
1301
|
pane_pid: None,
|
|
1194
1302
|
leader_env: BTreeMap::new(),
|
|
1195
1303
|
}]);
|
|
1304
|
+
let transport = transport.with_owned_session(&ws, "team-layout", "team-layout", "team-layout");
|
|
1196
1305
|
|
|
1197
1306
|
let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
|
|
1198
1307
|
.expect("shutdown should complete");
|
|
@@ -1239,6 +1348,8 @@ fn e49_managed_leader_shutdown_spares_leader_session_and_kills_workers_per_pane(
|
|
|
1239
1348
|
&ws,
|
|
1240
1349
|
&json!({
|
|
1241
1350
|
"session_name": "team-current",
|
|
1351
|
+
"team_key": "current",
|
|
1352
|
+
"generation": "current",
|
|
1242
1353
|
"is_external_leader": false,
|
|
1243
1354
|
"leader_receiver": {"pane_id": "%leader"},
|
|
1244
1355
|
"team_owner": {"pane_id": "%leader"},
|
|
@@ -1294,7 +1405,8 @@ fn e49_managed_leader_shutdown_spares_leader_session_and_kills_workers_per_pane(
|
|
|
1294
1405
|
leader_env: BTreeMap::new(),
|
|
1295
1406
|
},
|
|
1296
1407
|
])
|
|
1297
|
-
.with_targets_persist_after_kill()
|
|
1408
|
+
.with_targets_persist_after_kill()
|
|
1409
|
+
.with_owned_session(&ws, "team-current", "current", "current");
|
|
1298
1410
|
|
|
1299
1411
|
let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
|
|
1300
1412
|
.expect("shutdown should complete");
|
|
@@ -1381,6 +1493,8 @@ fn e49_external_leader_shutdown_still_kills_team_session_unconditionally() {
|
|
|
1381
1493
|
&ws,
|
|
1382
1494
|
&json!({
|
|
1383
1495
|
"session_name": "team-external",
|
|
1496
|
+
"team_key": "team-external",
|
|
1497
|
+
"generation": "team-external",
|
|
1384
1498
|
"is_external_leader": true,
|
|
1385
1499
|
"leader_receiver": {"pane_id": "%leaderelsewhere"},
|
|
1386
1500
|
"agents": {
|
|
@@ -1420,7 +1534,8 @@ fn e49_external_leader_shutdown_still_kills_team_session_unconditionally() {
|
|
|
1420
1534
|
leader_env: BTreeMap::new(),
|
|
1421
1535
|
},
|
|
1422
1536
|
])
|
|
1423
|
-
.with_targets_persist_after_kill()
|
|
1537
|
+
.with_targets_persist_after_kill()
|
|
1538
|
+
.with_owned_session(&ws, "team-external", "team-external", "team-external");
|
|
1424
1539
|
|
|
1425
1540
|
let event_log = crate::event_log::EventLog::new(&ws);
|
|
1426
1541
|
let event_start = event_log.tail(0).expect("events before shutdown").len();
|
|
@@ -1455,6 +1570,8 @@ fn e49_external_leader_non_clean_diagnostic_is_bounded_and_still_kills() {
|
|
|
1455
1570
|
&ws,
|
|
1456
1571
|
&json!({
|
|
1457
1572
|
"session_name": "team-external",
|
|
1573
|
+
"team_key": "team-external",
|
|
1574
|
+
"generation": "team-external",
|
|
1458
1575
|
"is_external_leader": true,
|
|
1459
1576
|
"leader_receiver": {"pane_id": "%leaderelsewhere"},
|
|
1460
1577
|
"agents": {
|
|
@@ -1477,7 +1594,8 @@ fn e49_external_leader_non_clean_diagnostic_is_bounded_and_still_kills() {
|
|
|
1477
1594
|
pane_pid: None,
|
|
1478
1595
|
leader_env: BTreeMap::new(),
|
|
1479
1596
|
}])
|
|
1480
|
-
.with_kill_session_error("deterministic E49 kill-session failure")
|
|
1597
|
+
.with_kill_session_error("deterministic E49 kill-session failure")
|
|
1598
|
+
.with_owned_session(&ws, "team-external", "team-external", "team-external");
|
|
1481
1599
|
|
|
1482
1600
|
let event_log = crate::event_log::EventLog::new(&ws);
|
|
1483
1601
|
let event_start = event_log.tail(0).expect("events before shutdown").len();
|
|
@@ -1537,6 +1655,8 @@ fn e49_managed_leader_without_anchor_in_session_still_kills_session() {
|
|
|
1537
1655
|
&ws,
|
|
1538
1656
|
&json!({
|
|
1539
1657
|
"session_name": "team-orphan",
|
|
1658
|
+
"team_key": "team-orphan",
|
|
1659
|
+
"generation": "team-orphan",
|
|
1540
1660
|
"is_external_leader": false,
|
|
1541
1661
|
"agents": {
|
|
1542
1662
|
"w1": {"status": "running", "provider": "codex", "window": "w1", "pane_id": "%w1"}
|
|
@@ -1558,7 +1678,8 @@ fn e49_managed_leader_without_anchor_in_session_still_kills_session() {
|
|
|
1558
1678
|
pane_pid: None,
|
|
1559
1679
|
leader_env: BTreeMap::new(),
|
|
1560
1680
|
}])
|
|
1561
|
-
.with_targets_persist_after_kill()
|
|
1681
|
+
.with_targets_persist_after_kill()
|
|
1682
|
+
.with_owned_session(&ws, "team-orphan", "team-orphan", "team-orphan");
|
|
1562
1683
|
|
|
1563
1684
|
let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
|
|
1564
1685
|
.expect("shutdown should complete");
|
|
@@ -1580,6 +1701,8 @@ fn shutdown_missing_topology_marker_defaults_to_managed_cleanup() {
|
|
|
1580
1701
|
&ws,
|
|
1581
1702
|
&json!({
|
|
1582
1703
|
"session_name": "team-current",
|
|
1704
|
+
"team_key": "team-current",
|
|
1705
|
+
"generation": "team-current",
|
|
1583
1706
|
"agents": {}
|
|
1584
1707
|
}),
|
|
1585
1708
|
)
|
|
@@ -1598,7 +1721,8 @@ fn shutdown_missing_topology_marker_defaults_to_managed_cleanup() {
|
|
|
1598
1721
|
pane_pid: None,
|
|
1599
1722
|
leader_env: BTreeMap::new(),
|
|
1600
1723
|
}])
|
|
1601
|
-
.with_targets_persist_after_kill()
|
|
1724
|
+
.with_targets_persist_after_kill()
|
|
1725
|
+
.with_owned_session(&ws, "team-current", "team-current", "team-current");
|
|
1602
1726
|
|
|
1603
1727
|
let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
|
|
1604
1728
|
.expect("shutdown should complete");
|
|
@@ -332,6 +332,7 @@ pub fn start_coordinator_with_team(
|
|
|
332
332
|
}
|
|
333
333
|
}
|
|
334
334
|
command
|
|
335
|
+
.current_dir(workspace.as_path())
|
|
335
336
|
.stdin(Stdio::null())
|
|
336
337
|
.stdout(Stdio::from(log))
|
|
337
338
|
.stderr(Stdio::from(log_err));
|
|
@@ -432,6 +433,16 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
|
|
|
432
433
|
pid: Some(pid),
|
|
433
434
|
});
|
|
434
435
|
}
|
|
436
|
+
let metadata_owned = read_coordinator_metadata(workspace)
|
|
437
|
+
.as_ref()
|
|
438
|
+
.is_some_and(|metadata| metadata.pid == pid);
|
|
439
|
+
if !metadata_owned || !coordinator_pid_owned_by_workspace(workspace, pid) {
|
|
440
|
+
return Ok(StopReport {
|
|
441
|
+
ok: false,
|
|
442
|
+
status: StopOutcome::NotOwned,
|
|
443
|
+
pid: Some(pid),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
435
446
|
if pid.get() == std::process::id() {
|
|
436
447
|
remove_file_if_exists(&pid_path)?;
|
|
437
448
|
remove_file_if_exists(&coordinator_meta_path(workspace))?;
|
|
@@ -441,7 +452,7 @@ pub fn stop_coordinator(workspace: &WorkspacePath) -> Result<StopReport, StopErr
|
|
|
441
452
|
pid: Some(pid),
|
|
442
453
|
});
|
|
443
454
|
}
|
|
444
|
-
if !
|
|
455
|
+
if !terminate_pid_for_workspace(pid, workspace.as_path()) {
|
|
445
456
|
return Ok(StopReport {
|
|
446
457
|
ok: false,
|
|
447
458
|
status: StopOutcome::KillFailed,
|
|
@@ -468,7 +479,7 @@ fn stop_discovered_coordinators(
|
|
|
468
479
|
let mut stopped = None;
|
|
469
480
|
let mut failed = None;
|
|
470
481
|
for pid in pids {
|
|
471
|
-
if
|
|
482
|
+
if terminate_pid_for_workspace(pid, workspace.as_path()) {
|
|
472
483
|
stopped.get_or_insert(pid);
|
|
473
484
|
} else {
|
|
474
485
|
failed.get_or_insert(pid);
|
|
@@ -501,47 +512,98 @@ fn discover_coordinator_pids(workspace: &WorkspacePath) -> Vec<Pid> {
|
|
|
501
512
|
_ => return Vec::new(),
|
|
502
513
|
};
|
|
503
514
|
let text = String::from_utf8_lossy(&output.stdout);
|
|
504
|
-
let
|
|
515
|
+
let metadata_pid = read_coordinator_metadata(workspace).map(|metadata| metadata.pid.get());
|
|
505
516
|
text.lines()
|
|
506
517
|
.filter_map(|line| parse_ps_command_line(line))
|
|
507
|
-
.filter(|(pid,
|
|
508
|
-
|
|
509
|
-
&&
|
|
518
|
+
.filter(|(pid, _command)| {
|
|
519
|
+
metadata_pid == Some(*pid)
|
|
520
|
+
&& *pid != std::process::id()
|
|
521
|
+
&& coordinator_pid_owned_by_workspace(workspace, Pid::new(*pid))
|
|
510
522
|
})
|
|
511
523
|
.map(|(pid, _)| Pid::new(pid))
|
|
512
524
|
.collect()
|
|
513
525
|
}
|
|
514
526
|
|
|
515
|
-
fn
|
|
516
|
-
let
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
Some((pid
|
|
527
|
+
fn coordinator_pid_owned_by_workspace(workspace: &WorkspacePath, pid: Pid) -> bool {
|
|
528
|
+
let workspace = workspace
|
|
529
|
+
.as_path()
|
|
530
|
+
.canonicalize()
|
|
531
|
+
.unwrap_or_else(|_| workspace.as_path().to_path_buf());
|
|
532
|
+
let Some(tokens) = crate::platform::argv::argv_tokens(pid.get()) else {
|
|
533
|
+
return false;
|
|
534
|
+
};
|
|
535
|
+
let is_team_agent = tokens.first().is_some_and(|token| is_team_agent_binary(token));
|
|
536
|
+
let is_coordinator = tokens.iter().any(|token| token == "coordinator");
|
|
537
|
+
let has_workspace = tokens.windows(2).any(|pair| {
|
|
538
|
+
pair[0] == "--workspace"
|
|
539
|
+
&& Path::new(&pair[1])
|
|
540
|
+
.canonicalize()
|
|
541
|
+
.map(|path| path == workspace)
|
|
542
|
+
.unwrap_or(false)
|
|
543
|
+
});
|
|
544
|
+
is_team_agent && is_coordinator && has_workspace
|
|
521
545
|
}
|
|
522
546
|
|
|
523
|
-
fn
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
547
|
+
fn terminate_pid_for_workspace(pid: Pid, workspace: &Path) -> bool {
|
|
548
|
+
if !coordinator_pid_owned_by_path(workspace, pid) {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
let pids = process_tree_pids(pid)
|
|
552
|
+
.into_iter()
|
|
553
|
+
.filter(|candidate| coordinator_pid_owned_by_path(workspace, *candidate))
|
|
554
|
+
.collect::<Vec<_>>();
|
|
555
|
+
if pids.is_empty() {
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
for child in pids.iter().rev() {
|
|
559
|
+
let _ = crate::platform::process::terminate_pid(
|
|
560
|
+
child.get(),
|
|
561
|
+
crate::platform::process::SignalKind::TerminateGraceful,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
if !wait_until_all_not_running(&pids, Duration::from_secs(5)) {
|
|
565
|
+
for child in pids.iter().rev() {
|
|
566
|
+
let _ = crate::platform::process::terminate_pid(
|
|
567
|
+
child.get(),
|
|
568
|
+
crate::platform::process::SignalKind::TerminateForce,
|
|
569
|
+
);
|
|
529
570
|
}
|
|
530
571
|
}
|
|
531
|
-
|
|
572
|
+
wait_until_all_not_running(&pids, Duration::from_secs(5))
|
|
532
573
|
}
|
|
533
574
|
|
|
534
|
-
fn
|
|
535
|
-
|
|
536
|
-
.
|
|
537
|
-
.
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
575
|
+
fn coordinator_pid_owned_by_path(workspace: &Path, pid: Pid) -> bool {
|
|
576
|
+
let workspace = workspace
|
|
577
|
+
.canonicalize()
|
|
578
|
+
.unwrap_or_else(|_| workspace.to_path_buf());
|
|
579
|
+
let Some(tokens) = crate::platform::argv::argv_tokens(pid.get()) else {
|
|
580
|
+
return false;
|
|
581
|
+
};
|
|
582
|
+
let is_team_agent = tokens.first().is_some_and(|token| is_team_agent_binary(token));
|
|
583
|
+
let is_coordinator = tokens.iter().any(|token| token == "coordinator");
|
|
584
|
+
let has_workspace = tokens.windows(2).any(|pair| {
|
|
585
|
+
pair[0] == "--workspace"
|
|
586
|
+
&& Path::new(&pair[1])
|
|
587
|
+
.canonicalize()
|
|
588
|
+
.map(|path| path == workspace)
|
|
589
|
+
.unwrap_or(false)
|
|
590
|
+
});
|
|
591
|
+
is_team_agent && is_coordinator && has_workspace
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
fn is_team_agent_binary(token: &str) -> bool {
|
|
595
|
+
let Some(name) = Path::new(token).file_name().and_then(|name| name.to_str()) else {
|
|
596
|
+
return false;
|
|
597
|
+
};
|
|
598
|
+
name == "team-agent" || name == "team_agent" || name.starts_with("team-agent-")
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
fn parse_ps_command_line(line: &str) -> Option<(u32, &str)> {
|
|
602
|
+
let line = line.trim_start();
|
|
603
|
+
let split = line.find(char::is_whitespace).unwrap_or(line.len());
|
|
604
|
+
let pid = line.get(..split)?.trim().parse::<u32>().ok()?;
|
|
605
|
+
let command = line.get(split..)?.trim();
|
|
606
|
+
Some((pid, command))
|
|
545
607
|
}
|
|
546
608
|
|
|
547
609
|
fn terminate_pid(pid: Pid) -> bool {
|