@team-agent/installer 0.5.92 → 0.5.97

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.
Files changed (32) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +381 -157
  4. package/crates/team-agent/src/cli/diagnose.rs +40 -10
  5. package/crates/team-agent/src/cli/emit.rs +6 -4
  6. package/crates/team-agent/src/cli/mod.rs +483 -470
  7. package/crates/team-agent/src/cli/models.rs +1 -0
  8. package/crates/team-agent/src/cli/spec.rs +2 -2
  9. package/crates/team-agent/src/cli/tests/base.rs +20 -10
  10. package/crates/team-agent/src/cli/tests/divergence.rs +12 -21
  11. package/crates/team-agent/src/cli/tests/main_preserved.rs +6 -1
  12. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +20 -11
  13. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +197 -30
  14. package/crates/team-agent/src/cli/triage.rs +31 -31
  15. package/crates/team-agent/src/cli/types.rs +3 -1
  16. package/crates/team-agent/src/coordinator/health.rs +92 -30
  17. package/crates/team-agent/src/coordinator/tick.rs +62 -8
  18. package/crates/team-agent/src/coordinator/types.rs +2 -0
  19. package/crates/team-agent/src/kill_audit.rs +41 -2
  20. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +48 -14
  21. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +4 -8
  22. package/crates/team-agent/src/lifecycle/launch.rs +17 -0
  23. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +26 -0
  24. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +10 -4
  25. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +10 -7
  26. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +91 -4
  27. package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +128 -11
  28. package/crates/team-agent/src/platform/process.rs +4 -45
  29. package/crates/team-agent/src/tmux_backend.rs +113 -3
  30. package/crates/team-agent/src/transport/test_support.rs +30 -0
  31. package/crates/team-agent/src/transport.rs +41 -0
  32. package/package.json +4 -4
@@ -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
- //! 独享 socket(无 spare)才允许整 server 拆;共享/leader 在 → 逐 session kill。
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,58 @@ fn anchors(raw: &[&str]) -> BTreeSet<String> {
23
23
  raw.iter().map(|s| s.to_string()).collect()
24
24
  }
25
25
 
26
- // RC-4(独享 socket):仅目标 session(无 spare)→ 整 server 拆。
27
26
  #[test]
28
- fn rc4_exclusive_socket_kills_server() {
27
+ fn leader_anchors_are_endpoint_scoped_and_unknown_anchors_stay_protected() {
28
+ use crate::cli::lifecycle_port::collect_state_leader_anchor_pane_ids as collect;
29
+ let state = json!({
30
+ "team_owner": {"pane_id": "%2"},
31
+ "leader_receiver": {"pane_id": "%2", "tmux_socket": "/tmp/s4-host.sock"},
32
+ "teams": {
33
+ "local": {
34
+ "team_owner": {"pane_id": "%3", "tmux_socket": "/tmp/s4-worker.sock"},
35
+ "leader_receiver": {"pane_id": "%3"}
36
+ },
37
+ "legacy": {"team_owner": {"pane_id": "%4"}}
38
+ }
39
+ });
40
+ assert_eq!(collect(&state, Some("/tmp/s4-worker.sock")), anchors(&["%3", "%4"]));
41
+ assert_eq!(collect(&state, Some("/tmp/s4-host.sock")), anchors(&["%2", "%4"]));
42
+ assert_eq!(collect(&state, None), anchors(&["%2", "%3", "%4"]));
43
+
44
+ // An unrelated receiver's socket is not evidence about the owner's pane.
45
+ let different_panes = json!({
46
+ "team_owner": {"pane_id": "%5"},
47
+ "leader_receiver": {"pane_id": "%2", "tmux_socket": "/tmp/s4-host.sock"}
48
+ });
49
+ assert_eq!(collect(&different_panes, Some("/tmp/s4-worker.sock")), anchors(&["%5"]));
50
+ }
51
+
52
+ #[test]
53
+ #[cfg(unix)]
54
+ fn leader_anchor_endpoint_aliases_preserve_same_server_protection() {
55
+ use crate::cli::lifecycle_port::collect_state_leader_anchor_pane_ids as collect;
56
+ let ws = tmp_shutdown_workspace("anchor-endpoint-alias");
57
+ let socket = ws.join("server.sock");
58
+ let alias = ws.join("alias.sock");
59
+ std::fs::write(&socket, b"fixture").unwrap();
60
+ std::os::unix::fs::symlink(&socket, &alias).unwrap();
61
+ let state = json!({"leader_receiver": {"pane_id": "%2", "tmux_socket": alias}});
62
+ assert_eq!(collect(&state, socket.to_str()), anchors(&["%2"]));
63
+ let short_name = format!("s4-anchor-{}", std::process::id());
64
+ let full_path = crate::tmux_backend::socket_path_for_name(&short_name).unwrap();
65
+ let state = json!({"leader_receiver": {"pane_id": "%2", "tmux_socket": short_name}});
66
+ assert_eq!(collect(&state, full_path.to_str()), anchors(&["%2"]));
67
+ }
68
+
69
+ // RC-4: even an apparently exclusive socket uses per-session cleanup.
70
+ #[test]
71
+ fn rc4_exclusive_socket_is_fail_closed() {
29
72
  assert_eq!(
30
73
  sessions_to_kill(&names(&["team-x", "team-y"]), &BTreeSet::new()),
31
- KillDecision::KillServerExclusive
74
+ KillDecision::KillIndividually {
75
+ to_kill: names(&["team-x", "team-y"]),
76
+ spared: vec![]
77
+ }
32
78
  );
33
79
  // 空 session 集 → 逐 kill(no-op),不整 server 拆(没东西可拆)。
34
80
  assert_eq!(
@@ -87,10 +133,13 @@ fn rc2_no_anchor_falls_back_to_naming() {
87
133
  &BTreeSet::new(),
88
134
  );
89
135
  assert!(matches!(with_leader, KillDecision::KillIndividually { .. }));
90
- // 无锚 + 无前缀(真损坏且 in_tmux 无前缀)→ 无 spare → 独享拆(退化兜底,与历史一致)。
136
+ // 无锚 + 无前缀仍只能逐 session 返回,实际 shutdown 会因 marker 缺失拒绝杀灭。
91
137
  assert_eq!(
92
138
  sessions_to_kill(&names(&["team-x"]), &BTreeSet::new()),
93
- KillDecision::KillServerExclusive
139
+ KillDecision::KillIndividually {
140
+ to_kill: names(&["team-x"]),
141
+ spared: vec![]
142
+ }
94
143
  );
95
144
  }
96
145
 
@@ -169,6 +218,8 @@ fn owned_empty_endpoint_cleanup_removes_socket_file_before_reporting_ok() {
169
218
  &ws,
170
219
  &json!({
171
220
  "session_name": "team-owned-clean",
221
+ "team_key": "team-owned-clean",
222
+ "generation": "team-owned-clean",
172
223
  "tmux_endpoint": socket.to_string_lossy(),
173
224
  "tmux_socket": socket.to_string_lossy(),
174
225
  "tmux_socket_source": "workspace",
@@ -177,7 +228,12 @@ fn owned_empty_endpoint_cleanup_removes_socket_file_before_reporting_ok() {
177
228
  }),
178
229
  )
179
230
  .unwrap();
180
- let transport = CleanShutdownTransport::new();
231
+ let transport = CleanShutdownTransport::new().with_owned_session(
232
+ &ws,
233
+ "team-owned-clean",
234
+ "team-owned-clean",
235
+ "team-owned-clean",
236
+ );
181
237
 
182
238
  let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
183
239
  .expect("shutdown should complete");
@@ -186,12 +242,12 @@ fn owned_empty_endpoint_cleanup_removes_socket_file_before_reporting_ok() {
186
242
  assert_eq!(out["status"], json!("ok"));
187
243
  assert_eq!(out["residuals"]["owned_files"], json!([]));
188
244
  assert!(
189
- !socket.exists(),
190
- "owned empty endpoint socket file must be removed"
245
+ socket.exists(),
246
+ "fail-closed endpoint cleanup must preserve the socket file"
191
247
  );
192
248
  assert!(
193
- transport.kill_server_called(),
194
- "owned empty endpoint should be torn down after session cleanup"
249
+ !transport.kill_server_called(),
250
+ "endpoint ownership must not authorize server teardown"
195
251
  );
196
252
  }
197
253
 
@@ -235,6 +291,8 @@ fn owned_file_residual_makes_shutdown_failed_not_partial() {
235
291
  &ws,
236
292
  &json!({
237
293
  "session_name": "team-owned-residual",
294
+ "team_key": "team-owned-residual",
295
+ "generation": "team-owned-residual",
238
296
  "tmux_endpoint": socket_dir.to_string_lossy(),
239
297
  "tmux_socket": socket_dir.to_string_lossy(),
240
298
  "tmux_socket_source": "workspace",
@@ -243,18 +301,22 @@ fn owned_file_residual_makes_shutdown_failed_not_partial() {
243
301
  }),
244
302
  )
245
303
  .unwrap();
246
- let transport = CleanShutdownTransport::new();
304
+ let transport = CleanShutdownTransport::new().with_owned_session(
305
+ &ws,
306
+ "team-owned-residual",
307
+ "team-owned-residual",
308
+ "team-owned-residual",
309
+ );
247
310
 
248
311
  let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
249
312
  .expect("shutdown should complete");
250
313
 
251
- assert_eq!(out["ok"], json!(false));
252
- assert_eq!(out["status"], json!("failed"));
314
+ assert_eq!(out["ok"], json!(true));
315
+ assert_eq!(out["status"], json!("ok"));
253
316
  assert_eq!(out["phase"], json!(null));
254
- assert_eq!(
255
- out["residuals"]["owned_files"],
256
- json!([{ "path": socket_dir.display().to_string() }])
257
- );
317
+ assert_eq!(out["residuals"]["owned_files"], json!([]));
318
+ assert!(socket_dir.exists(), "fail-closed cleanup preserves residual files");
319
+ assert!(!transport.kill_server_called());
258
320
  }
259
321
 
260
322
  #[test]
@@ -340,6 +402,7 @@ fn scoped_clean_shutdown_persists_team_shutdown_status_after_disk_roundtrip() {
340
402
  "schema_version": 1,
341
403
  "active_team_key": "current",
342
404
  "team_key": "current",
405
+ "generation": "current",
343
406
  "session_name": "team-current",
344
407
  "team_dir": ws.display().to_string(),
345
408
  "workspace": ws.display().to_string(),
@@ -354,6 +417,7 @@ fn scoped_clean_shutdown_persists_team_shutdown_status_after_disk_roundtrip() {
354
417
  "teams": {
355
418
  "current": {
356
419
  "team_key": "current",
420
+ "generation": "current",
357
421
  "session_name": "team-current",
358
422
  "team_dir": ws.display().to_string(),
359
423
  "workspace": ws.display().to_string(),
@@ -375,7 +439,12 @@ fn scoped_clean_shutdown_persists_team_shutdown_status_after_disk_roundtrip() {
375
439
  &ws,
376
440
  true,
377
441
  Some("current"),
378
- &CleanShutdownTransport::new(),
442
+ &CleanShutdownTransport::new().with_owned_session(
443
+ &ws,
444
+ "team-current",
445
+ "current",
446
+ "current",
447
+ ),
379
448
  )
380
449
  .expect("shutdown should complete");
381
450
 
@@ -414,6 +483,7 @@ fn scoped_degraded_shutdown_does_not_persist_team_shutdown_status() {
414
483
  "schema_version": 1,
415
484
  "active_team_key": "current",
416
485
  "team_key": "current",
486
+ "generation": "current",
417
487
  "session_name": "team-current",
418
488
  "agents": {
419
489
  "adminweb": {
@@ -426,6 +496,7 @@ fn scoped_degraded_shutdown_does_not_persist_team_shutdown_status() {
426
496
  "teams": {
427
497
  "current": {
428
498
  "team_key": "current",
499
+ "generation": "current",
429
500
  "session_name": "team-current",
430
501
  "agents": {
431
502
  "adminweb": {
@@ -445,7 +516,9 @@ fn scoped_degraded_shutdown_does_not_persist_team_shutdown_status() {
445
516
  &ws,
446
517
  true,
447
518
  Some("current"),
448
- &CleanShutdownTransport::new().with_probe_timeout("ps_table"),
519
+ &CleanShutdownTransport::new()
520
+ .with_owned_session(&ws, "team-current", "current", "current")
521
+ .with_probe_timeout("ps_table"),
449
522
  )
450
523
  .expect("shutdown should complete");
451
524
 
@@ -519,6 +592,8 @@ fn repeated_owned_endpoint_shutdowns_leave_no_socket_file_growth() {
519
592
  &ws,
520
593
  &json!({
521
594
  "session_name": "team-owned-loop",
595
+ "team_key": "team-owned-loop",
596
+ "generation": "team-owned-loop",
522
597
  "tmux_endpoint": socket.to_string_lossy(),
523
598
  "tmux_socket": socket.to_string_lossy(),
524
599
  "tmux_socket_source": "workspace",
@@ -535,7 +610,12 @@ fn repeated_owned_endpoint_shutdowns_leave_no_socket_file_growth() {
535
610
  &ws,
536
611
  true,
537
612
  None,
538
- &CleanShutdownTransport::new(),
613
+ &CleanShutdownTransport::new().with_owned_session(
614
+ &ws,
615
+ "team-owned-loop",
616
+ "team-owned-loop",
617
+ "team-owned-loop",
618
+ ),
539
619
  );
540
620
  let out = match &result {
541
621
  Ok(out) if !shutdown_result_is_degraded(out) => out,
@@ -573,8 +653,9 @@ fn repeated_owned_endpoint_shutdowns_leave_no_socket_file_growth() {
573
653
  }
574
654
  let ending = sockets.iter().filter(|path| path.exists()).count();
575
655
  assert_eq!(
576
- ending, starting,
577
- "owned socket files must not grow across loops"
656
+ ending,
657
+ starting + sockets.len(),
658
+ "fail-closed endpoint cleanup must not remove unverified socket files"
578
659
  );
579
660
  #[cfg(unix)]
580
661
  if expected_probe_failure {
@@ -793,6 +874,7 @@ struct CleanShutdownTransport {
793
874
  probe_timeout_kind: Option<&'static str>,
794
875
  targets_persist_after_kill: bool,
795
876
  kill_session_error: Option<String>,
877
+ owner: Option<crate::transport::SessionOwner>,
796
878
  // E49 (0.3.24 P0, shutdown kills leader CLI): record per-pane / per-window /
797
879
  // per-session kill targets so RED contracts can assert (a) the leader pane is
798
880
  // never killed and (b) worker panes ARE killed via the new per-pane path.
@@ -810,6 +892,7 @@ impl CleanShutdownTransport {
810
892
  probe_timeout_kind: None,
811
893
  targets_persist_after_kill: false,
812
894
  kill_session_error: None,
895
+ owner: None,
813
896
  killed_panes: Mutex::new(Vec::new()),
814
897
  killed_window_targets: Mutex::new(Vec::new()),
815
898
  killed_sessions: Mutex::new(Vec::new()),
@@ -821,6 +904,46 @@ impl CleanShutdownTransport {
821
904
  self
822
905
  }
823
906
 
907
+ fn with_owned_session(
908
+ mut self,
909
+ workspace: &Path,
910
+ session: &str,
911
+ team: &str,
912
+ generation: &str,
913
+ ) -> Self {
914
+ self.owner = Some(crate::transport::SessionOwner {
915
+ workspace: workspace
916
+ .canonicalize()
917
+ .unwrap_or_else(|_| workspace.to_path_buf())
918
+ .to_string_lossy()
919
+ .into_owned(),
920
+ team: team.to_string(),
921
+ generation: generation.to_string(),
922
+ });
923
+ if self.targets.is_empty() {
924
+ self.targets.push(PaneInfo {
925
+ pane_id: PaneId::new("%owned"),
926
+ session: SessionName::new(session),
927
+ window_index: Some(0),
928
+ window_name: Some(WindowName::new("owned")),
929
+ pane_index: Some(0),
930
+ tty: None,
931
+ current_command: Some("codex".to_string()),
932
+ current_path: Some(workspace.to_path_buf()),
933
+ active: true,
934
+ pane_pid: None,
935
+ leader_env: BTreeMap::new(),
936
+ });
937
+ } else {
938
+ for target in &mut self.targets {
939
+ if target.current_path.is_none() {
940
+ target.current_path = Some(workspace.to_path_buf());
941
+ }
942
+ }
943
+ }
944
+ self
945
+ }
946
+
824
947
  fn kill_server_called(&self) -> bool {
825
948
  *self.kill_server_called.lock().unwrap()
826
949
  }
@@ -931,6 +1054,13 @@ impl Transport for CleanShutdownTransport {
931
1054
  Ok(*self.session_present.lock().unwrap())
932
1055
  }
933
1056
 
1057
+ fn session_owner(
1058
+ &self,
1059
+ _session: &SessionName,
1060
+ ) -> Result<Option<crate::transport::SessionOwner>, TransportError> {
1061
+ Ok(self.owner.clone())
1062
+ }
1063
+
934
1064
  fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
935
1065
  Ok(Vec::new())
936
1066
  }
@@ -1001,6 +1131,8 @@ fn lsof_cwd_timeout_is_diagnostic_not_shutdown_partial() {
1001
1131
  &ws,
1002
1132
  &json!({
1003
1133
  "session_name": "team-lsof-cwd-timeout",
1134
+ "team_key": "team-lsof-cwd-timeout",
1135
+ "generation": "team-lsof-cwd-timeout",
1004
1136
  "is_external_leader": true,
1005
1137
  "agents": {
1006
1138
  "fake_impl": {
@@ -1017,7 +1149,14 @@ fn lsof_cwd_timeout_is_diagnostic_not_shutdown_partial() {
1017
1149
  &ws,
1018
1150
  true,
1019
1151
  None,
1020
- &CleanShutdownTransport::new().with_probe_timeout("lsof_cwd"),
1152
+ &CleanShutdownTransport::new()
1153
+ .with_owned_session(
1154
+ &ws,
1155
+ "team-lsof-cwd-timeout",
1156
+ "team-lsof-cwd-timeout",
1157
+ "team-lsof-cwd-timeout",
1158
+ )
1159
+ .with_probe_timeout("lsof_cwd"),
1021
1160
  )
1022
1161
  .expect("shutdown should complete");
1023
1162
 
@@ -1051,6 +1190,8 @@ fn ps_table_timeout_still_degrades_shutdown_truth() {
1051
1190
  &ws,
1052
1191
  &json!({
1053
1192
  "session_name": "team-ps-table-timeout",
1193
+ "team_key": "team-ps-table-timeout",
1194
+ "generation": "team-ps-table-timeout",
1054
1195
  "agents": {
1055
1196
  "fake_impl": {
1056
1197
  "status": "running",
@@ -1066,7 +1207,14 @@ fn ps_table_timeout_still_degrades_shutdown_truth() {
1066
1207
  &ws,
1067
1208
  true,
1068
1209
  None,
1069
- &CleanShutdownTransport::new().with_probe_timeout("ps_table"),
1210
+ &CleanShutdownTransport::new()
1211
+ .with_owned_session(
1212
+ &ws,
1213
+ "team-ps-table-timeout",
1214
+ "team-ps-table-timeout",
1215
+ "team-ps-table-timeout",
1216
+ )
1217
+ .with_probe_timeout("ps_table"),
1070
1218
  )
1071
1219
  .expect("shutdown should complete");
1072
1220
 
@@ -1169,6 +1317,9 @@ fn leader_env_tmux_socket_never_kills_server_even_when_sessions_look_exclusive()
1169
1317
  &ws,
1170
1318
  &json!({
1171
1319
  "is_external_leader": true,
1320
+ "team_key": "team-layout",
1321
+ "generation": "team-layout",
1322
+ "session_name": "team-layout",
1172
1323
  "tmux_socket_source": "leader_env",
1173
1324
  "agents": {
1174
1325
  "fake_impl": {
@@ -1193,6 +1344,7 @@ fn leader_env_tmux_socket_never_kills_server_even_when_sessions_look_exclusive()
1193
1344
  pane_pid: None,
1194
1345
  leader_env: BTreeMap::new(),
1195
1346
  }]);
1347
+ let transport = transport.with_owned_session(&ws, "team-layout", "team-layout", "team-layout");
1196
1348
 
1197
1349
  let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
1198
1350
  .expect("shutdown should complete");
@@ -1239,6 +1391,8 @@ fn e49_managed_leader_shutdown_spares_leader_session_and_kills_workers_per_pane(
1239
1391
  &ws,
1240
1392
  &json!({
1241
1393
  "session_name": "team-current",
1394
+ "team_key": "current",
1395
+ "generation": "current",
1242
1396
  "is_external_leader": false,
1243
1397
  "leader_receiver": {"pane_id": "%leader"},
1244
1398
  "team_owner": {"pane_id": "%leader"},
@@ -1294,7 +1448,8 @@ fn e49_managed_leader_shutdown_spares_leader_session_and_kills_workers_per_pane(
1294
1448
  leader_env: BTreeMap::new(),
1295
1449
  },
1296
1450
  ])
1297
- .with_targets_persist_after_kill();
1451
+ .with_targets_persist_after_kill()
1452
+ .with_owned_session(&ws, "team-current", "current", "current");
1298
1453
 
1299
1454
  let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
1300
1455
  .expect("shutdown should complete");
@@ -1381,6 +1536,8 @@ fn e49_external_leader_shutdown_still_kills_team_session_unconditionally() {
1381
1536
  &ws,
1382
1537
  &json!({
1383
1538
  "session_name": "team-external",
1539
+ "team_key": "team-external",
1540
+ "generation": "team-external",
1384
1541
  "is_external_leader": true,
1385
1542
  "leader_receiver": {"pane_id": "%leaderelsewhere"},
1386
1543
  "agents": {
@@ -1420,7 +1577,8 @@ fn e49_external_leader_shutdown_still_kills_team_session_unconditionally() {
1420
1577
  leader_env: BTreeMap::new(),
1421
1578
  },
1422
1579
  ])
1423
- .with_targets_persist_after_kill();
1580
+ .with_targets_persist_after_kill()
1581
+ .with_owned_session(&ws, "team-external", "team-external", "team-external");
1424
1582
 
1425
1583
  let event_log = crate::event_log::EventLog::new(&ws);
1426
1584
  let event_start = event_log.tail(0).expect("events before shutdown").len();
@@ -1455,6 +1613,8 @@ fn e49_external_leader_non_clean_diagnostic_is_bounded_and_still_kills() {
1455
1613
  &ws,
1456
1614
  &json!({
1457
1615
  "session_name": "team-external",
1616
+ "team_key": "team-external",
1617
+ "generation": "team-external",
1458
1618
  "is_external_leader": true,
1459
1619
  "leader_receiver": {"pane_id": "%leaderelsewhere"},
1460
1620
  "agents": {
@@ -1477,7 +1637,8 @@ fn e49_external_leader_non_clean_diagnostic_is_bounded_and_still_kills() {
1477
1637
  pane_pid: None,
1478
1638
  leader_env: BTreeMap::new(),
1479
1639
  }])
1480
- .with_kill_session_error("deterministic E49 kill-session failure");
1640
+ .with_kill_session_error("deterministic E49 kill-session failure")
1641
+ .with_owned_session(&ws, "team-external", "team-external", "team-external");
1481
1642
 
1482
1643
  let event_log = crate::event_log::EventLog::new(&ws);
1483
1644
  let event_start = event_log.tail(0).expect("events before shutdown").len();
@@ -1537,6 +1698,8 @@ fn e49_managed_leader_without_anchor_in_session_still_kills_session() {
1537
1698
  &ws,
1538
1699
  &json!({
1539
1700
  "session_name": "team-orphan",
1701
+ "team_key": "team-orphan",
1702
+ "generation": "team-orphan",
1540
1703
  "is_external_leader": false,
1541
1704
  "agents": {
1542
1705
  "w1": {"status": "running", "provider": "codex", "window": "w1", "pane_id": "%w1"}
@@ -1558,7 +1721,8 @@ fn e49_managed_leader_without_anchor_in_session_still_kills_session() {
1558
1721
  pane_pid: None,
1559
1722
  leader_env: BTreeMap::new(),
1560
1723
  }])
1561
- .with_targets_persist_after_kill();
1724
+ .with_targets_persist_after_kill()
1725
+ .with_owned_session(&ws, "team-orphan", "team-orphan", "team-orphan");
1562
1726
 
1563
1727
  let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
1564
1728
  .expect("shutdown should complete");
@@ -1580,6 +1744,8 @@ fn shutdown_missing_topology_marker_defaults_to_managed_cleanup() {
1580
1744
  &ws,
1581
1745
  &json!({
1582
1746
  "session_name": "team-current",
1747
+ "team_key": "team-current",
1748
+ "generation": "team-current",
1583
1749
  "agents": {}
1584
1750
  }),
1585
1751
  )
@@ -1598,7 +1764,8 @@ fn shutdown_missing_topology_marker_defaults_to_managed_cleanup() {
1598
1764
  pane_pid: None,
1599
1765
  leader_env: BTreeMap::new(),
1600
1766
  }])
1601
- .with_targets_persist_after_kill();
1767
+ .with_targets_persist_after_kill()
1768
+ .with_owned_session(&ws, "team-current", "team-current", "team-current");
1602
1769
 
1603
1770
  let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
1604
1771
  .expect("shutdown should complete");
@@ -35,43 +35,27 @@ pub(crate) fn render(command: &str, report: &Value) -> String {
35
35
  // generic emitter redacts again for defense in depth.
36
36
  let report = crate::redaction::redact_external_value(report);
37
37
  let ok = report.get("ok").and_then(Value::as_bool).unwrap_or(false);
38
- let mut lines = vec![bounded(format!(
38
+ let runtime = report
39
+ .get("runtime")
40
+ .and_then(|runtime| runtime.get("status"))
41
+ .and_then(Value::as_str);
42
+ let issues = array_items(report.get("issues"));
43
+ let repairs = array_items(report.get("suggested_repairs"));
44
+ let mut summary = format!(
39
45
  "{command}: {}",
40
46
  if ok { "ok" } else { "needs attention" }
41
- ))];
42
-
43
- if command == "doctor" {
44
- for finding in array_items(
45
- report
46
- .get("secret_scan")
47
- .and_then(|scan| scan.get("findings")),
48
- ) {
49
- let Some(object) = finding.as_object() else {
50
- continue;
51
- };
52
- let (Some(rule), Some(path), Some(line)) = (
53
- object.get("rule").and_then(Value::as_str),
54
- object.get("path").and_then(Value::as_str),
55
- object.get("line").and_then(Value::as_u64),
56
- ) else {
57
- continue;
58
- };
59
- lines.push(bounded(format!("warn: {rule} in {path}:{line}")));
60
- }
47
+ );
48
+ if let Some(runtime) = runtime {
49
+ summary.push_str(&format!("; runtime={runtime}"));
61
50
  }
62
-
63
- for issue in array_items(report.get("issues")) {
51
+ summary.push_str(&format!("; issues={}; repairs={}", issues.len(), repairs.len()));
52
+ let mut lines = vec![bounded(summary)];
53
+ for issue in issues {
64
54
  lines.push(bounded(format!("issue: {}", summarize(issue, false))));
65
55
  }
66
- for repair in array_items(report.get("suggested_repairs")) {
56
+ for repair in repairs {
67
57
  lines.push(bounded(format!("repair: {}", summarize(repair, true))));
68
58
  }
69
- if let Some(error) = report.get("error").filter(|value| !value.is_null()) {
70
- lines.push(bounded(format!("detail: {}", summarize(error, false))));
71
- }
72
- if lines.len() == 1 {
73
- lines.push(bounded("detail: no issues detected".to_string()));
74
- }
75
59
  lines.join("\n")
76
60
  }
77
61
 
@@ -95,6 +79,15 @@ fn summarize(value: &Value, repair: bool) -> String {
95
79
  .as_slice()
96
80
  };
97
81
  let mut parts = Vec::new();
82
+ if let Some(path) = object.get("path").and_then(Value::as_str) {
83
+ let location = match object.get("line").and_then(Value::as_u64) {
84
+ Some(line) => format!("{path}:{line}"),
85
+ None => path.to_string(),
86
+ };
87
+ // Keep the target ahead of prose so bounded human lines retain
88
+ // the distinguishing location rather than only the issue id.
89
+ parts.push(format!("at={location}"));
90
+ }
98
91
  for key in keys {
99
92
  if let Some(value) = object.get(*key).and_then(Value::as_str) {
100
93
  parts.push(format!("{key}={value}"));
@@ -166,6 +159,13 @@ mod tests {
166
159
  fn renderer_projects_secret_findings_without_values() {
167
160
  let report = json!({
168
161
  "ok": false,
162
+ "issues": [{
163
+ "id": "secret_scan_finding",
164
+ "rule": "api_key_assignment",
165
+ "path": "leaky-role.md",
166
+ "line": 7,
167
+ }],
168
+ "suggested_repairs": [{"issue": "secret_scan_finding", "action": "remove secret finding at leaky-role.md:7"}],
169
169
  "secret_scan": {
170
170
  "findings": [{
171
171
  "rule": "api_key_assignment",
@@ -176,7 +176,7 @@ mod tests {
176
176
  }
177
177
  });
178
178
  let output = render("doctor", &report);
179
- assert!(output.contains("warn: api_key_assignment in leaky-role.md:7"));
179
+ assert!(output.contains("issue: at=leaky-role.md:7; id=secret_scan_finding"));
180
180
  assert!(!output.contains("match_excerpt"));
181
181
  assert!(!output.contains("OPENAI_API_KEY=secret"));
182
182
  }
@@ -697,7 +697,9 @@ pub struct WaitArgs {
697
697
  pub json: bool,
698
698
  }
699
699
 
700
- /// `diagnose`(`parser.py:298`) runtime health report, distinct from `doctor`.
700
+ /// `diagnose` compatibility spelling for the canonical `doctor` health report.
701
+ /// The CLI normalizes it to `DoctorArgs`; this narrow type remains for direct
702
+ /// Rust callers that used the pre-unification API.
701
703
  #[derive(Debug, Clone, PartialEq, Eq)]
702
704
  pub struct DiagnoseArgs {
703
705
  pub workspace: PathBuf,