@team-agent/installer 0.3.14 → 0.3.16

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 (46) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/emit.rs +92 -11
  5. package/crates/team-agent/src/cli/leader.rs +2 -0
  6. package/crates/team-agent/src/cli/mod.rs +255 -37
  7. package/crates/team-agent/src/cli/send.rs +252 -0
  8. package/crates/team-agent/src/cli/status.rs +5 -1
  9. package/crates/team-agent/src/cli/status_port.rs +24 -0
  10. package/crates/team-agent/src/cli/tests/base.rs +21 -3
  11. package/crates/team-agent/src/cli/tests/divergence.rs +6 -6
  12. package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
  13. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +285 -2
  14. package/crates/team-agent/src/cli/tests/status_send.rs +25 -0
  15. package/crates/team-agent/src/cli/types.rs +32 -1
  16. package/crates/team-agent/src/compiler/tests.rs +2 -2
  17. package/crates/team-agent/src/compiler.rs +1 -1
  18. package/crates/team-agent/src/fake_worker.rs +32 -145
  19. package/crates/team-agent/src/leader/start.rs +272 -8
  20. package/crates/team-agent/src/leader/tests/basics.rs +1 -0
  21. package/crates/team-agent/src/leader/tests/identity.rs +121 -25
  22. package/crates/team-agent/src/leader/types.rs +9 -0
  23. package/crates/team-agent/src/lifecycle/display.rs +3 -3
  24. package/crates/team-agent/src/lifecycle/launch.rs +631 -70
  25. package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +38 -2
  27. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +79 -4
  28. package/crates/team-agent/src/lifecycle/tests/core.rs +14 -5
  29. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +731 -8
  30. package/crates/team-agent/src/lifecycle/types.rs +5 -0
  31. package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
  32. package/crates/team-agent/src/mcp_server/wire.rs +153 -3
  33. package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
  34. package/crates/team-agent/src/messaging/mod.rs +6 -3
  35. package/crates/team-agent/src/messaging/results.rs +240 -158
  36. package/crates/team-agent/src/messaging/send.rs +3 -2
  37. package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
  38. package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
  39. package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
  40. package/crates/team-agent/src/os_probe.rs +11 -0
  41. package/crates/team-agent/src/state/persist.rs +6 -0
  42. package/crates/team-agent/src/tmux_backend.rs +85 -0
  43. package/crates/team-agent/src/transport/test_support.rs +46 -1
  44. package/crates/team-agent/src/transport.rs +27 -0
  45. package/package.json +4 -4
  46. package/skills/team-agent/references/recovery-runbook.md +277 -0
@@ -1,6 +1,7 @@
1
1
  use super::*;
2
2
  use crate::transport::test_support::OfflineTransport;
3
3
  use serial_test::serial;
4
+ use serde_json::json;
4
5
 
5
6
  const QS_TEAM_MD: &str =
6
7
  "---\nname: quickteam\nobjective: Quick start.\nprovider: codex\n---\n\nQuick-start team.\n";
@@ -18,6 +19,38 @@ pub(super) fn quick_start_team_dir(role_doc: &str) -> PathBuf {
18
19
  team
19
20
  }
20
21
 
22
+ fn quick_start_team_dir_with_roles(role_docs: &[(&str, &str)]) -> PathBuf {
23
+ let team = temp_ws().join("teamdir");
24
+ std::fs::create_dir_all(team.join("agents")).unwrap();
25
+ std::fs::write(team.join("TEAM.md"), QS_TEAM_MD).unwrap();
26
+ for (file, role_doc) in role_docs {
27
+ std::fs::write(team.join("agents").join(file), role_doc).unwrap();
28
+ }
29
+ team
30
+ }
31
+
32
+ fn role_doc(id: &str) -> String {
33
+ format!(
34
+ "---\nname: {id}\nrole: {id} Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\n{id} worker.\n"
35
+ )
36
+ }
37
+
38
+ fn layout_pane(session: &str, window: &str, pane: &str, pane_index: u32) -> crate::transport::PaneInfo {
39
+ crate::transport::PaneInfo {
40
+ pane_id: crate::transport::PaneId::new(pane),
41
+ session: crate::transport::SessionName::new(session),
42
+ window_index: None,
43
+ window_name: Some(crate::transport::WindowName::new(window)),
44
+ pane_index: Some(pane_index),
45
+ tty: None,
46
+ current_command: Some("codex".to_string()),
47
+ current_path: None,
48
+ active: pane_index == 0,
49
+ pane_pid: None,
50
+ leader_env: std::collections::BTreeMap::new(),
51
+ }
52
+ }
53
+
21
54
  /// E5 spec 迁移:spec 不再落 team_dir,而在 <team_workspace>/.team/runtime/<team_key>/。
22
55
  /// team_key = team_dir.name(quick_start_team_dir → "teamdir");workspace = team_workspace(team)。
23
56
  pub(super) fn quick_start_runtime_spec_path(team: &std::path::Path) -> PathBuf {
@@ -506,11 +539,20 @@ fn spine_restart_empty_team_is_not_atomic_refusal() {
506
539
  );
507
540
  }
508
541
 
509
- // #14 — add_agent rejects a duplicate agent id (golden operations.py:301 'agent id already exists'),
510
- // BEFORE any full recompile. Current does a full compile_team with no duplicate-id guard.
542
+ // E25 — add_agent duplicate membership is runtime-state based, not role-doc/spec-file based.
511
543
  #[test]
512
544
  fn spine_add_agent_rejects_duplicate_agent_id() {
513
545
  let team = quick_start_team_dir(QS_VALID_ROLE); // team already has agent 'implementer'
546
+ crate::state::persist::save_runtime_state(
547
+ &team,
548
+ &json!({
549
+ "session_name": "team-quickteam",
550
+ "agents": {
551
+ "implementer": {"status": "running", "provider": "codex", "window": "implementer"}
552
+ }
553
+ }),
554
+ )
555
+ .unwrap();
514
556
  let role = team.join("dup-role.md");
515
557
  std::fs::write(&role, QS_VALID_ROLE).unwrap(); // a role doc named 'implementer' = duplicate
516
558
  let transport = OfflineTransport::new();
@@ -525,7 +567,7 @@ fn spine_add_agent_rejects_duplicate_agent_id() {
525
567
  let text = format!("{result:?}");
526
568
  assert!(
527
569
  text.contains("already exists"),
528
- "add_agent must reject a duplicate agent id (golden 'agent id already exists'); got {text}"
570
+ "add_agent must reject an id already present in runtime state; got {text}"
529
571
  );
530
572
  }
531
573
 
@@ -631,7 +673,8 @@ fn red2_restart_empty_workspace_still_reports_missing() {
631
673
  text.contains("missing spec")
632
674
  || text.to_lowercase().contains("not found")
633
675
  || text.contains("no_local_team_context")
634
- || text.to_lowercase().contains("invalid workspace"),
676
+ || text.to_lowercase().contains("invalid workspace")
677
+ || text.contains("role definitions missing"),
635
678
  "RED-2 negative: empty workspace restart must still error (missing/not-found); got {text}"
636
679
  );
637
680
  let _ = std::fs::remove_dir_all(&ws);
@@ -1008,6 +1051,263 @@ fn quick_start_with_transport_spawns_workers_not_dry_run() {
1008
1051
  );
1009
1052
  }
1010
1053
 
1054
+ #[test]
1055
+ fn adaptive_layout_plan_8_workers_is_3_3_2() {
1056
+ let agents = (1..=8)
1057
+ .map(|i| AgentId::new(format!("w{i}")))
1058
+ .collect::<Vec<_>>();
1059
+ let plan = adaptive_layout_plan(&agents, 3);
1060
+ let windows = plan
1061
+ .iter()
1062
+ .map(|placement| placement.layout_window.as_str().to_string())
1063
+ .collect::<Vec<_>>();
1064
+ assert_eq!(
1065
+ windows,
1066
+ vec![
1067
+ "team-w1", "team-w1", "team-w1", "team-w2", "team-w2", "team-w2", "team-w3",
1068
+ "team-w3",
1069
+ ]
1070
+ );
1071
+ let pane_counts = ["team-w1", "team-w2", "team-w3"]
1072
+ .into_iter()
1073
+ .map(|window| windows.iter().filter(|actual| actual.as_str() == window).count())
1074
+ .collect::<Vec<_>>();
1075
+ assert_eq!(pane_counts, vec![3, 3, 2]);
1076
+ assert_eq!(
1077
+ plan.iter()
1078
+ .filter(|placement| placement.starts_window)
1079
+ .map(|placement| placement.agent_id.as_str().to_string())
1080
+ .collect::<Vec<_>>(),
1081
+ vec!["w1", "w4", "w7"]
1082
+ );
1083
+ }
1084
+
1085
+ #[test]
1086
+ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
1087
+ let roles = (1..=4)
1088
+ .map(|i| {
1089
+ let id = format!("w{i}");
1090
+ (format!("{id}.md"), role_doc(&id))
1091
+ })
1092
+ .collect::<Vec<_>>();
1093
+ let role_refs = roles
1094
+ .iter()
1095
+ .map(|(file, doc)| (file.as_str(), doc.as_str()))
1096
+ .collect::<Vec<_>>();
1097
+ let team = quick_start_team_dir_with_roles(&role_refs);
1098
+ let workspace = team.parent().expect("team_workspace(team_dir) = parent");
1099
+ seed_healthy_coordinator(workspace);
1100
+ let transport = OfflineTransport::new();
1101
+
1102
+ let report = quick_start_with_transport(&team, None, true, true, None, &transport)
1103
+ .expect("quick_start_with_transport must reach Ready");
1104
+
1105
+ let (launch, attach_commands, display_backend) = match report {
1106
+ QuickStartReport::Ready { launch, attach_commands, display_backend, .. } => {
1107
+ (*launch, attach_commands, display_backend)
1108
+ }
1109
+ other => panic!("quick_start must reach Ready; got {other:?}"),
1110
+ };
1111
+ assert_eq!(display_backend, "adaptive");
1112
+ assert_eq!(
1113
+ transport
1114
+ .spawn_records()
1115
+ .iter()
1116
+ .map(|(kind, _)| kind.as_str())
1117
+ .collect::<Vec<_>>(),
1118
+ vec!["spawn_first", "spawn_split", "spawn_split", "spawn_into"],
1119
+ "4 default-adaptive workers should create team-w1 with 3 panes, then team-w2"
1120
+ );
1121
+ assert_eq!(
1122
+ launch
1123
+ .started
1124
+ .iter()
1125
+ .map(|started| {
1126
+ (
1127
+ started.agent_id.as_str().to_string(),
1128
+ started.layout_window.as_ref().map(|w| w.as_str().to_string()),
1129
+ started.pane_index,
1130
+ )
1131
+ })
1132
+ .collect::<Vec<_>>(),
1133
+ vec![
1134
+ ("w1".to_string(), Some("team-w1".to_string()), Some(0)),
1135
+ ("w2".to_string(), Some("team-w1".to_string()), Some(1)),
1136
+ ("w3".to_string(), Some("team-w1".to_string()), Some(2)),
1137
+ ("w4".to_string(), Some("team-w2".to_string()), Some(0)),
1138
+ ]
1139
+ );
1140
+ assert!(
1141
+ attach_commands.iter().any(|cmd| cmd.contains(":team-w1")),
1142
+ "attach commands must include the first layout window: {attach_commands:?}"
1143
+ );
1144
+ assert!(
1145
+ attach_commands.iter().any(|cmd| cmd.contains(":team-w2")),
1146
+ "attach commands must include the second layout window: {attach_commands:?}"
1147
+ );
1148
+
1149
+ let (_raw, state) = raw_runtime_state(workspace);
1150
+ assert_eq!(state["display_backend"], json!("adaptive"));
1151
+ assert_eq!(state.pointer("/agents/w1/window"), Some(&json!("team-w1")));
1152
+ assert_eq!(state.pointer("/agents/w1/layout_window"), Some(&json!("team-w1")));
1153
+ assert_eq!(state.pointer("/agents/w1/pane_index"), Some(&json!(0)));
1154
+ assert_eq!(
1155
+ state.pointer("/agents/w1/display/backend"),
1156
+ Some(&json!("adaptive"))
1157
+ );
1158
+ assert_eq!(
1159
+ state.pointer("/agents/w4/layout_window"),
1160
+ Some(&json!("team-w2"))
1161
+ );
1162
+ }
1163
+
1164
+ #[test]
1165
+ #[serial(env)]
1166
+ fn quick_start_tmux_backend_prefers_absolute_tmux_env_endpoint() {
1167
+ let leader_socket = "/tmp/team-agent-layout-leader-socket";
1168
+ let _tmux = EnvVarGuard::set("TMUX", &format!("{leader_socket},123,0"));
1169
+ let backend = quick_start_tmux_backend(std::path::Path::new("/tmp/layout-workspace"));
1170
+
1171
+ assert_eq!(
1172
+ crate::transport::Transport::tmux_endpoint(&backend).as_deref(),
1173
+ Some(leader_socket),
1174
+ "quick-start from inside tmux must use the caller's full $TMUX socket endpoint"
1175
+ );
1176
+ assert_eq!(
1177
+ selected_tmux_socket_source(&backend, std::path::Path::new("/tmp/layout-workspace")),
1178
+ Some("leader_env")
1179
+ );
1180
+ }
1181
+
1182
+ #[test]
1183
+ fn quick_start_persists_selected_tmux_endpoint_and_attach_commands() {
1184
+ let team = quick_start_team_dir(QS_VALID_ROLE);
1185
+ let workspace = team.parent().expect("team_workspace(team_dir) = parent");
1186
+ seed_healthy_coordinator(workspace);
1187
+ let endpoint = "/tmp/team-agent-layout-selected-socket";
1188
+ let transport = OfflineTransport::new().with_tmux_endpoint(endpoint);
1189
+
1190
+ let report = quick_start_with_transport(&team, None, true, true, None, &transport)
1191
+ .expect("quick_start_with_transport must reach Ready");
1192
+ let attach_commands = match report {
1193
+ QuickStartReport::Ready { attach_commands, .. } => attach_commands,
1194
+ other => panic!("quick_start must reach Ready; got {other:?}"),
1195
+ };
1196
+
1197
+ assert!(
1198
+ attach_commands
1199
+ .iter()
1200
+ .any(|cmd| cmd.contains(&format!("tmux -S {endpoint} attach -t "))),
1201
+ "attach commands must use the selected socket endpoint: {attach_commands:?}"
1202
+ );
1203
+ let (_raw, state) = raw_runtime_state(workspace);
1204
+ assert_eq!(state["tmux_endpoint"], json!(endpoint));
1205
+ assert_eq!(state["tmux_socket"], json!(endpoint));
1206
+ }
1207
+
1208
+ #[test]
1209
+ fn quick_start_preserves_managed_leader_topology_and_emits_leader_attach_command() {
1210
+ let team = quick_start_team_dir(QS_VALID_ROLE);
1211
+ let workspace = team.parent().expect("team_workspace(team_dir) = parent");
1212
+ seed_healthy_coordinator(workspace);
1213
+ crate::state::persist::save_runtime_state(
1214
+ workspace,
1215
+ &json!({
1216
+ "active_team_key": "teamdir",
1217
+ "session_name": "team-teamdir",
1218
+ "is_external_leader": false,
1219
+ "leader_client": {"diagnostic_only": true, "attach_mode": "attach-session"},
1220
+ "leader_receiver": {"mode": "direct_tmux", "status": "attached", "pane_id": "%42"},
1221
+ "team_owner": {"pane_id": "%42", "owner_epoch": 1},
1222
+ "agents": {},
1223
+ "teams": {
1224
+ "teamdir": {
1225
+ "session_name": "team-teamdir",
1226
+ "is_external_leader": false,
1227
+ "leader_client": {"diagnostic_only": true, "attach_mode": "attach-session"},
1228
+ "agents": {}
1229
+ }
1230
+ }
1231
+ }),
1232
+ )
1233
+ .expect("seed managed leader state");
1234
+ let transport = OfflineTransport::new();
1235
+
1236
+ let report = quick_start_with_transport(&team, None, true, true, None, &transport)
1237
+ .expect("quick_start_with_transport must reach Ready");
1238
+ let attach_commands = match report {
1239
+ QuickStartReport::Ready { attach_commands, .. } => attach_commands,
1240
+ other => panic!("quick_start must reach Ready; got {other:?}"),
1241
+ };
1242
+
1243
+ assert!(
1244
+ attach_commands.iter().any(|cmd| cmd.contains(":leader")),
1245
+ "managed topology quick-start output must include the leader window attach command: {attach_commands:?}"
1246
+ );
1247
+ let (_raw, state) = raw_runtime_state(workspace);
1248
+ assert_eq!(state["is_external_leader"], json!(false));
1249
+ assert_eq!(state["leader_client"]["diagnostic_only"], json!(true));
1250
+ }
1251
+
1252
+ #[test]
1253
+ fn attach_window_names_for_state_agents_include_managed_leader_and_layout_windows() {
1254
+ let state = json!({
1255
+ "is_external_leader": false,
1256
+ "agents": {
1257
+ "w1": {"layout_window": "team-w1", "window": "w1"},
1258
+ "w2": {"layout_window": "team-w1", "window": "w2"},
1259
+ "w4": {"layout_window": "team-w2", "window": "w4"}
1260
+ }
1261
+ });
1262
+
1263
+ let windows = crate::lifecycle::launch::attach_window_names_for_state_agents(
1264
+ &state,
1265
+ ["w1", "w2", "w4"].into_iter(),
1266
+ );
1267
+
1268
+ assert_eq!(windows, vec!["leader", "team-w1", "team-w2"]);
1269
+ }
1270
+
1271
+ #[test]
1272
+ fn quick_start_no_display_keeps_one_window_per_agent() {
1273
+ let roles = ["w1", "w2"]
1274
+ .into_iter()
1275
+ .map(|id| (format!("{id}.md"), role_doc(id)))
1276
+ .collect::<Vec<_>>();
1277
+ let role_refs = roles
1278
+ .iter()
1279
+ .map(|(file, doc)| (file.as_str(), doc.as_str()))
1280
+ .collect::<Vec<_>>();
1281
+ let team = quick_start_team_dir_with_roles(&role_refs);
1282
+ let workspace = team.parent().expect("team_workspace(team_dir) = parent");
1283
+ seed_healthy_coordinator(workspace);
1284
+ let transport = OfflineTransport::new();
1285
+
1286
+ let report = quick_start_with_transport_in_workspace_with_display(
1287
+ workspace, &team, None, true, true, None, &transport, false,
1288
+ )
1289
+ .expect("quick_start_with_transport must reach Ready");
1290
+
1291
+ let display_backend = match report {
1292
+ QuickStartReport::Ready { display_backend, .. } => display_backend,
1293
+ other => panic!("quick_start must reach Ready; got {other:?}"),
1294
+ };
1295
+ assert_eq!(display_backend, "none");
1296
+ assert_eq!(
1297
+ transport
1298
+ .spawn_records()
1299
+ .iter()
1300
+ .map(|(kind, _)| kind.as_str())
1301
+ .collect::<Vec<_>>(),
1302
+ vec!["spawn_first", "spawn_into"],
1303
+ "--no-display must use the legacy one-worker-window spawn path"
1304
+ );
1305
+ let (_raw, state) = raw_runtime_state(workspace);
1306
+ assert_eq!(state["display_backend"], json!("none"));
1307
+ assert_eq!(state.pointer("/agents/w1/window"), Some(&json!("w1")));
1308
+ assert_eq!(state.pointer("/agents/w1/layout_window"), None);
1309
+ }
1310
+
1011
1311
  // REAL-MACHINE residency boundary (acceptance framework): the PUBLIC quick_start (real TmuxBackend +
1012
1312
  // real start_coordinator) on a fresh ws must leave a LIVE tmux session AND a ps-verifiable resident
1013
1313
  // coordinator daemon (start_coordinator -> live pid). The framework verifies residency via ps; here we
@@ -1453,6 +1753,419 @@ fn add_agent_with_transport_spawns_new_worker_not_stub() {
1453
1753
  );
1454
1754
  }
1455
1755
 
1756
+ #[test]
1757
+ fn add_agent_role_doc_exists_runtime_missing_succeeds() {
1758
+ let team = temp_ws().join("add_role_doc_team");
1759
+ std::fs::create_dir_all(team.join("agents")).unwrap();
1760
+ std::fs::write(
1761
+ team.join("TEAM.md"),
1762
+ "---\nname: addroledoc\nobjective: Add role-doc probe.\nprovider: codex\n---\n\nteam.\n",
1763
+ )
1764
+ .unwrap();
1765
+ std::fs::write(team.join("agents").join("implementer.md"), QS_VALID_ROLE).unwrap();
1766
+ let role_file = team.join("agents").join("worker2.md");
1767
+ std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
1768
+ crate::state::persist::save_runtime_state(
1769
+ &team,
1770
+ &json!({
1771
+ "session_name": "team-addroledoc",
1772
+ "agents": {
1773
+ "implementer": {"status": "running", "provider": "codex", "window": "implementer"}
1774
+ }
1775
+ }),
1776
+ )
1777
+ .unwrap();
1778
+ seed_healthy_coordinator(&team);
1779
+ let transport = OfflineTransport::new();
1780
+
1781
+ let result = add_agent_with_transport(
1782
+ &team,
1783
+ &AgentId::new("worker2"),
1784
+ &role_file,
1785
+ false,
1786
+ None,
1787
+ &transport,
1788
+ );
1789
+
1790
+ assert!(
1791
+ result.is_ok(),
1792
+ "a prepared agents/<id>.md role doc is not membership; runtime state is authoritative: {result:?}"
1793
+ );
1794
+ let state = crate::state::persist::load_runtime_state(&team).expect("load state");
1795
+ assert!(
1796
+ state.pointer("/agents/worker2").is_some(),
1797
+ "add-agent must add worker2 to runtime state; state={state}"
1798
+ );
1799
+ assert_eq!(
1800
+ transport.spawn_records().len(),
1801
+ 1,
1802
+ "add-agent must spawn exactly the new worker"
1803
+ );
1804
+ }
1805
+
1806
+ #[test]
1807
+ fn start_agent_runtime_missing_role_doc_exists_no_side_effect() {
1808
+ let team = temp_ws().join("start_missing_team");
1809
+ std::fs::create_dir_all(team.join("agents")).unwrap();
1810
+ std::fs::write(
1811
+ team.join("TEAM.md"),
1812
+ "---\nname: startmissing\nobjective: Start missing probe.\nprovider: codex\n---\n\nteam.\n",
1813
+ )
1814
+ .unwrap();
1815
+ std::fs::write(team.join("agents").join("reviewer.md"), DELEG_ROLE_WORKER2).unwrap();
1816
+ crate::state::persist::save_runtime_state(
1817
+ &team,
1818
+ &json!({
1819
+ "session_name": "team-startmissing",
1820
+ "agents": {
1821
+ "implementer": {"status": "running", "provider": "codex", "window": "implementer"}
1822
+ }
1823
+ }),
1824
+ )
1825
+ .unwrap();
1826
+ let transport = OfflineTransport::new();
1827
+
1828
+ let result = start_agent_with_transport(
1829
+ &team,
1830
+ &AgentId::new("reviewer"),
1831
+ false,
1832
+ false,
1833
+ true,
1834
+ None,
1835
+ &transport,
1836
+ );
1837
+
1838
+ let text = format!("{result:?}");
1839
+ assert!(
1840
+ text.contains("not found"),
1841
+ "start-agent must refuse runtime-missing agents before side effects; got {text}"
1842
+ );
1843
+ assert!(
1844
+ transport.spawn_records().is_empty(),
1845
+ "runtime-missing start-agent must not spawn a pane"
1846
+ );
1847
+ assert!(
1848
+ !team
1849
+ .join(".team")
1850
+ .join("runtime")
1851
+ .join("mcp")
1852
+ .join("reviewer.json")
1853
+ .exists(),
1854
+ "runtime-missing start-agent must not write worker MCP config"
1855
+ );
1856
+ let events = crate::event_log::EventLog::new(&team)
1857
+ .tail(20)
1858
+ .unwrap_or_default();
1859
+ assert!(
1860
+ !events.iter().any(|event| {
1861
+ event.get("event").and_then(|v| v.as_str()) == Some("start_agent.agent_start")
1862
+ }),
1863
+ "runtime-missing start-agent must not emit start_agent.agent_start; events={events:?}"
1864
+ );
1865
+ }
1866
+
1867
+ #[test]
1868
+ fn restart_allow_fresh_copilot_persists_expected_session_id() {
1869
+ let ws = temp_ws().join("restartcopilot");
1870
+ std::fs::create_dir_all(ws.join("agents")).unwrap();
1871
+ std::fs::write(
1872
+ ws.join("TEAM.md"),
1873
+ "---\nname: restartcopilot\nobjective: Restart copilot probe.\nprovider: copilot\n---\n\nteam.\n",
1874
+ )
1875
+ .unwrap();
1876
+ std::fs::write(
1877
+ ws.join("agents").join("alpha.md"),
1878
+ "---\nname: alpha\nrole: Alpha Worker\nprovider: copilot\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nAlpha.\n",
1879
+ )
1880
+ .unwrap();
1881
+ let spec = crate::compiler::compile_team(&ws).expect("compile copilot team");
1882
+ std::fs::write(ws.join("team.spec.yaml"), crate::model::yaml::dumps(&spec)).unwrap();
1883
+ crate::state::persist::save_runtime_state(
1884
+ &ws,
1885
+ &json!({
1886
+ "session_name": "team-restartcopilot",
1887
+ "agents": {
1888
+ "alpha": {
1889
+ "status": "running",
1890
+ "provider": "copilot",
1891
+ "window": "alpha",
1892
+ "first_send_at": "2026-05-27T10:00:00+00:00",
1893
+ "session_id": null,
1894
+ "rollout_path": "/tmp/stale-copilot-rollout.jsonl",
1895
+ "captured_at": "2026-05-27T10:00:00+00:00",
1896
+ "captured_via": "stale",
1897
+ "attribution_confidence": "stale"
1898
+ }
1899
+ }
1900
+ }),
1901
+ )
1902
+ .unwrap();
1903
+ seed_healthy_coordinator(&ws);
1904
+ let transport = OfflineTransport::new();
1905
+
1906
+ let result = restart_with_transport(&ws, true, None, &transport);
1907
+
1908
+ assert!(
1909
+ matches!(result, Ok(RestartReport::Restarted { .. })),
1910
+ "allow-fresh restart should fresh-spawn the copilot worker; got {result:?}"
1911
+ );
1912
+ let recorded = transport.spawn_records();
1913
+ assert_eq!(
1914
+ recorded.len(),
1915
+ 1,
1916
+ "expected one copilot spawn; got {recorded:?}"
1917
+ );
1918
+ let session_arg = recorded[0]
1919
+ .1
1920
+ .windows(2)
1921
+ .find_map(|pair| (pair[0] == "--session-id").then(|| pair[1].clone()))
1922
+ .expect("copilot fresh spawn argv must include --session-id");
1923
+ let state = crate::state::persist::load_runtime_state(&ws).expect("load state");
1924
+ let agent = state.pointer("/agents/alpha").expect("alpha state");
1925
+ assert_eq!(
1926
+ agent.get("session_id").and_then(serde_json::Value::as_str),
1927
+ Some(session_arg.as_str()),
1928
+ "fresh restart must promote expected_session_id to persisted session_id"
1929
+ );
1930
+ assert_eq!(
1931
+ agent
1932
+ .get("_pending_session_id")
1933
+ .and_then(serde_json::Value::as_str),
1934
+ Some(session_arg.as_str()),
1935
+ "fresh restart must retain pending session audit state"
1936
+ );
1937
+ for stale in [
1938
+ "rollout_path",
1939
+ "captured_at",
1940
+ "captured_via",
1941
+ "attribution_confidence",
1942
+ ] {
1943
+ assert_eq!(
1944
+ agent.get(stale),
1945
+ Some(&serde_json::Value::Null),
1946
+ "fresh restart must clear stale capture field {stale}: {agent}"
1947
+ );
1948
+ }
1949
+ let plan = crate::lifecycle::restart::classify_restart_plan(&state, false)
1950
+ .expect("classify second restart");
1951
+ assert!(
1952
+ plan.unresumable
1953
+ .iter()
1954
+ .all(|worker| worker.reason != "no_persisted_session_id"),
1955
+ "second restart must not loop as no_persisted_session_id; unresumable={:?}",
1956
+ plan.unresumable
1957
+ );
1958
+ }
1959
+
1960
+ #[test]
1961
+ fn add_agent_adaptive_splits_last_non_full_layout_window() {
1962
+ let roles = [("w1.md", role_doc("w1")), ("w2.md", role_doc("w2"))];
1963
+ let role_refs = roles
1964
+ .iter()
1965
+ .map(|(file, doc)| (*file, doc.as_str()))
1966
+ .collect::<Vec<_>>();
1967
+ let team = quick_start_team_dir_with_roles(&role_refs);
1968
+ let session = "team-layout-add";
1969
+ crate::state::persist::save_runtime_state(
1970
+ &team,
1971
+ &json!({
1972
+ "session_name": session,
1973
+ "display_backend": "adaptive",
1974
+ "active_team_key": "teamdir",
1975
+ "agents": {
1976
+ "w1": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 0, "pane_id": "%1"},
1977
+ "w2": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 1, "pane_id": "%2"}
1978
+ }
1979
+ }),
1980
+ )
1981
+ .unwrap();
1982
+ seed_healthy_coordinator(&team);
1983
+ let role_file = team.join("w3-role.md");
1984
+ std::fs::write(&role_file, role_doc("w3")).unwrap();
1985
+ let transport = OfflineTransport::new()
1986
+ .with_session_present(true)
1987
+ .with_targets(vec![
1988
+ layout_pane(session, "team-w1", "%1", 0),
1989
+ layout_pane(session, "team-w1", "%2", 1),
1990
+ ])
1991
+ .with_pane_presence("%1", true)
1992
+ .with_pane_presence("%2", true);
1993
+
1994
+ add_agent_with_transport(
1995
+ &team,
1996
+ &AgentId::new("w3"),
1997
+ &role_file,
1998
+ true,
1999
+ None,
2000
+ &transport,
2001
+ )
2002
+ .expect("adaptive add-agent should start");
2003
+
2004
+ assert_eq!(
2005
+ transport.spawn_window_records().last(),
2006
+ Some(&(String::from("spawn_split"), String::from("team-w1"))),
2007
+ "last non-full layout window should be split"
2008
+ );
2009
+ let (_raw, state) = raw_runtime_state(&team);
2010
+ assert_eq!(state.pointer("/agents/w3/layout_window"), Some(&json!("team-w1")));
2011
+ assert_eq!(state.pointer("/agents/w3/pane_index"), Some(&json!(2)));
2012
+ }
2013
+
2014
+ #[test]
2015
+ fn add_agent_adaptive_creates_suffix_window_when_last_layout_full_and_name_collides() {
2016
+ let roles = [
2017
+ ("w1.md", role_doc("w1")),
2018
+ ("w2.md", role_doc("w2")),
2019
+ ("w3.md", role_doc("w3")),
2020
+ ];
2021
+ let role_refs = roles
2022
+ .iter()
2023
+ .map(|(file, doc)| (*file, doc.as_str()))
2024
+ .collect::<Vec<_>>();
2025
+ let team = quick_start_team_dir_with_roles(&role_refs);
2026
+ let session = "team-layout-add-full";
2027
+ crate::state::persist::save_runtime_state(
2028
+ &team,
2029
+ &json!({
2030
+ "session_name": session,
2031
+ "display_backend": "adaptive",
2032
+ "active_team_key": "teamdir",
2033
+ "agents": {
2034
+ "w1": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 0, "pane_id": "%1"},
2035
+ "w2": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 1, "pane_id": "%2"},
2036
+ "w3": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 2, "pane_id": "%3"}
2037
+ }
2038
+ }),
2039
+ )
2040
+ .unwrap();
2041
+ seed_healthy_coordinator(&team);
2042
+ let role_file = team.join("w4-role.md");
2043
+ std::fs::write(&role_file, role_doc("w4")).unwrap();
2044
+ let transport = OfflineTransport::new()
2045
+ .with_session_present(true)
2046
+ .with_targets(vec![
2047
+ layout_pane(session, "team-w1", "%1", 0),
2048
+ layout_pane(session, "team-w1", "%2", 1),
2049
+ layout_pane(session, "team-w1", "%3", 2),
2050
+ layout_pane(session, "team-w2", "%9", 0),
2051
+ ])
2052
+ .with_pane_presence("%1", true)
2053
+ .with_pane_presence("%2", true)
2054
+ .with_pane_presence("%3", true);
2055
+
2056
+ add_agent_with_transport(
2057
+ &team,
2058
+ &AgentId::new("w4"),
2059
+ &role_file,
2060
+ true,
2061
+ None,
2062
+ &transport,
2063
+ )
2064
+ .expect("adaptive add-agent should start");
2065
+
2066
+ assert_eq!(
2067
+ transport.spawn_window_records().last(),
2068
+ Some(&(String::from("spawn_into"), String::from("team-w2-2"))),
2069
+ "full last layout window should create next window with collision suffix"
2070
+ );
2071
+ let (_raw, state) = raw_runtime_state(&team);
2072
+ assert_eq!(state.pointer("/agents/w4/layout_window"), Some(&json!("team-w2-2")));
2073
+ assert_eq!(state.pointer("/agents/w4/pane_index"), Some(&json!(0)));
2074
+ }
2075
+
2076
+ #[test]
2077
+ fn stop_agent_adaptive_kills_target_pane_not_shared_layout_window() {
2078
+ let roles = [("w1.md", role_doc("w1")), ("w2.md", role_doc("w2"))];
2079
+ let role_refs = roles
2080
+ .iter()
2081
+ .map(|(file, doc)| (*file, doc.as_str()))
2082
+ .collect::<Vec<_>>();
2083
+ let team = quick_start_team_dir_with_roles(&role_refs);
2084
+ let spec = crate::compiler::compile_team(&team).unwrap();
2085
+ std::fs::write(team.join("team.spec.yaml"), crate::model::yaml::dumps(&spec)).unwrap();
2086
+ let session = "team-layout-stop";
2087
+ crate::state::persist::save_runtime_state(
2088
+ &team,
2089
+ &json!({
2090
+ "session_name": session,
2091
+ "display_backend": "adaptive",
2092
+ "agents": {
2093
+ "w1": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 0, "pane_id": "%1"},
2094
+ "w2": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 1, "pane_id": "%2"}
2095
+ }
2096
+ }),
2097
+ )
2098
+ .unwrap();
2099
+ let transport = OfflineTransport::new()
2100
+ .with_targets(vec![
2101
+ layout_pane(session, "team-w1", "%1", 0),
2102
+ layout_pane(session, "team-w1", "%2", 1),
2103
+ ])
2104
+ .with_pane_presence("%2", true);
2105
+
2106
+ let report =
2107
+ stop_agent_with_transport(&team, &AgentId::new("w2"), None, &transport).unwrap();
2108
+
2109
+ assert!(report.stopped);
2110
+ assert_eq!(report.target, "%2");
2111
+ assert!(
2112
+ transport.calls().iter().any(|call| *call == "kill_pane"),
2113
+ "adaptive stop must kill only the target pane"
2114
+ );
2115
+ assert!(
2116
+ !transport.calls().iter().any(|call| *call == "kill_window"),
2117
+ "adaptive stop must not kill the shared layout window"
2118
+ );
2119
+ let (_raw, state) = raw_runtime_state(&team);
2120
+ assert_eq!(state.pointer("/agents/w1/pane_id"), Some(&json!("%1")));
2121
+ assert_eq!(state.pointer("/agents/w1/layout_window"), Some(&json!("team-w1")));
2122
+ }
2123
+
2124
+ #[test]
2125
+ fn start_agent_adaptive_restarts_missing_pane_in_existing_layout_window() {
2126
+ let ws = temp_ws();
2127
+ crate::state::persist::save_runtime_state(
2128
+ &ws,
2129
+ &json!({
2130
+ "session_name": "team-layout-restart",
2131
+ "display_backend": "adaptive",
2132
+ "agents": {
2133
+ "w1": {"status": "running", "provider": "codex", "role": "w1", "model": "gpt-5.5", "auth_mode": "subscription", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 0, "pane_id": "%1"},
2134
+ "w2": {"status": "running", "provider": "codex", "role": "w2", "model": "gpt-5.5", "auth_mode": "subscription", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 1, "pane_id": "%2"}
2135
+ }
2136
+ }),
2137
+ )
2138
+ .unwrap();
2139
+ seed_healthy_coordinator(&ws);
2140
+ let transport = OfflineTransport::new()
2141
+ .with_session_present(true)
2142
+ .with_targets(vec![layout_pane("team-layout-restart", "team-w1", "%2", 1)])
2143
+ .with_pane_presence("%1", false)
2144
+ .with_pane_presence("%2", true);
2145
+
2146
+ let outcome = start_agent_with_transport(
2147
+ &ws,
2148
+ &AgentId::new("w1"),
2149
+ false,
2150
+ true,
2151
+ true,
2152
+ None,
2153
+ &transport,
2154
+ )
2155
+ .expect("adaptive restart should spawn");
2156
+
2157
+ assert!(matches!(outcome, StartAgentOutcome::Running { .. }));
2158
+ assert_eq!(
2159
+ transport.spawn_window_records().last(),
2160
+ Some(&(String::from("spawn_split"), String::from("team-w1"))),
2161
+ "missing worker pane should be rebuilt in its existing layout window"
2162
+ );
2163
+ let (_raw, state) = raw_runtime_state(&ws);
2164
+ assert_eq!(state.pointer("/agents/w1/layout_window"), Some(&json!("team-w1")));
2165
+ assert_eq!(state.pointer("/agents/w1/window"), Some(&json!("team-w1")));
2166
+ assert_eq!(state.pointer("/agents/w2/pane_id"), Some(&json!("%2")));
2167
+ }
2168
+
1456
2169
  // ═════════════════════════════════════════════════════════════════════════
1457
2170
  // WAVE 2 · LANE A — agent-lifecycle byte-parity contracts. stop_agent / reset_agent / remove_agent /
1458
2171
  // fork_agent are stubs (OwnerRefused / RequirementUnmet / "session_id missing"). These RED contracts
@@ -1592,8 +2305,8 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
1592
2305
  );
1593
2306
  assert_eq!(
1594
2307
  state["display_backend"],
1595
- json!("none"),
1596
- "golden resolve_display_backend(None, source='launch') defaults to none"
2308
+ json!("adaptive"),
2309
+ "adaptive layout directive: resolve_display_backend(None, source='launch') defaults to adaptive"
1597
2310
  );
1598
2311
  }
1599
2312
 
@@ -1640,11 +2353,15 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
1640
2353
  "captured_at",
1641
2354
  "captured_via",
1642
2355
  "attribution_confidence",
2356
+ "layout_window",
2357
+ "layout_index",
2358
+ "pane_index",
2359
+ "display",
1643
2360
  "spawn_cwd",
1644
2361
  "spawned_at",
1645
2362
  "pane_id",
1646
2363
  ],
1647
- "running agent state key order must match golden launch/core.py:238-255; raw={raw}"
2364
+ "running agent state key order must include adaptive layout fields; raw={raw}"
1648
2365
  );
1649
2366
  assert_eq!(agent["status"], json!("running"));
1650
2367
  assert_eq!(agent["provider"], json!("codex"));
@@ -1652,7 +2369,7 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
1652
2369
  assert_eq!(agent["model"], json!("gpt-5.5"));
1653
2370
  assert_eq!(agent["auth_mode"], json!("subscription"));
1654
2371
  assert!(agent["profile"].is_null());
1655
- assert_eq!(agent["window"], json!("implementer"));
2372
+ assert_eq!(agent["window"], json!("team-w1"));
1656
2373
  assert_eq!(
1657
2374
  agent["mcp_config"],
1658
2375
  json!(workspace
@@ -1674,6 +2391,12 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
1674
2391
  assert!(agent["captured_at"].is_null());
1675
2392
  assert!(agent["captured_via"].is_null());
1676
2393
  assert!(agent["attribution_confidence"].is_null());
2394
+ assert_eq!(agent["layout_window"], json!("team-w1"));
2395
+ assert_eq!(agent["layout_index"], json!(0));
2396
+ assert_eq!(agent["pane_index"], json!(0));
2397
+ assert_eq!(agent.pointer("/display/backend"), Some(&json!("adaptive")));
2398
+ assert_eq!(agent.pointer("/display/window"), Some(&json!("team-w1")));
2399
+ assert_eq!(agent.pointer("/display/pane_id"), Some(&json!("%0")));
1677
2400
  // D5 (#264) / Python launch/core.py:253 — fresh launch persists spawn_cwd=workspace.
1678
2401
  assert_eq!(agent["spawn_cwd"], json!(workspace.to_string_lossy()));
1679
2402
  assert_eq!(agent["spawned_at"], json!(FIXED_SPAWNED_AT));