@team-agent/installer 0.3.14 → 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.
Files changed (36) 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/mod.rs +60 -10
  6. package/crates/team-agent/src/cli/send.rs +252 -0
  7. package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +159 -2
  9. package/crates/team-agent/src/cli/types.rs +30 -1
  10. package/crates/team-agent/src/compiler/tests.rs +2 -2
  11. package/crates/team-agent/src/compiler.rs +1 -1
  12. package/crates/team-agent/src/fake_worker.rs +32 -145
  13. package/crates/team-agent/src/lifecycle/display.rs +3 -3
  14. package/crates/team-agent/src/lifecycle/launch.rs +560 -69
  15. package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
  16. package/crates/team-agent/src/lifecycle/restart/common.rs +38 -2
  17. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +72 -1
  18. package/crates/team-agent/src/lifecycle/tests/core.rs +14 -5
  19. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +668 -8
  20. package/crates/team-agent/src/lifecycle/types.rs +5 -0
  21. package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
  22. package/crates/team-agent/src/mcp_server/wire.rs +153 -3
  23. package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
  24. package/crates/team-agent/src/messaging/mod.rs +6 -3
  25. package/crates/team-agent/src/messaging/results.rs +240 -158
  26. package/crates/team-agent/src/messaging/send.rs +3 -2
  27. package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
  28. package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
  29. package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
  30. package/crates/team-agent/src/os_probe.rs +11 -0
  31. package/crates/team-agent/src/state/persist.rs +6 -0
  32. package/crates/team-agent/src/tmux_backend.rs +85 -0
  33. package/crates/team-agent/src/transport/test_support.rs +46 -1
  34. package/crates/team-agent/src/transport.rs +27 -0
  35. package/package.json +4 -4
  36. 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,200 @@ 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_no_display_keeps_one_window_per_agent() {
1210
+ let roles = ["w1", "w2"]
1211
+ .into_iter()
1212
+ .map(|id| (format!("{id}.md"), role_doc(id)))
1213
+ .collect::<Vec<_>>();
1214
+ let role_refs = roles
1215
+ .iter()
1216
+ .map(|(file, doc)| (file.as_str(), doc.as_str()))
1217
+ .collect::<Vec<_>>();
1218
+ let team = quick_start_team_dir_with_roles(&role_refs);
1219
+ let workspace = team.parent().expect("team_workspace(team_dir) = parent");
1220
+ seed_healthy_coordinator(workspace);
1221
+ let transport = OfflineTransport::new();
1222
+
1223
+ let report = quick_start_with_transport_in_workspace_with_display(
1224
+ workspace, &team, None, true, true, None, &transport, false,
1225
+ )
1226
+ .expect("quick_start_with_transport must reach Ready");
1227
+
1228
+ let display_backend = match report {
1229
+ QuickStartReport::Ready { display_backend, .. } => display_backend,
1230
+ other => panic!("quick_start must reach Ready; got {other:?}"),
1231
+ };
1232
+ assert_eq!(display_backend, "none");
1233
+ assert_eq!(
1234
+ transport
1235
+ .spawn_records()
1236
+ .iter()
1237
+ .map(|(kind, _)| kind.as_str())
1238
+ .collect::<Vec<_>>(),
1239
+ vec!["spawn_first", "spawn_into"],
1240
+ "--no-display must use the legacy one-worker-window spawn path"
1241
+ );
1242
+ let (_raw, state) = raw_runtime_state(workspace);
1243
+ assert_eq!(state["display_backend"], json!("none"));
1244
+ assert_eq!(state.pointer("/agents/w1/window"), Some(&json!("w1")));
1245
+ assert_eq!(state.pointer("/agents/w1/layout_window"), None);
1246
+ }
1247
+
1011
1248
  // REAL-MACHINE residency boundary (acceptance framework): the PUBLIC quick_start (real TmuxBackend +
1012
1249
  // real start_coordinator) on a fresh ws must leave a LIVE tmux session AND a ps-verifiable resident
1013
1250
  // coordinator daemon (start_coordinator -> live pid). The framework verifies residency via ps; here we
@@ -1453,6 +1690,419 @@ fn add_agent_with_transport_spawns_new_worker_not_stub() {
1453
1690
  );
1454
1691
  }
1455
1692
 
1693
+ #[test]
1694
+ fn add_agent_role_doc_exists_runtime_missing_succeeds() {
1695
+ let team = temp_ws().join("add_role_doc_team");
1696
+ std::fs::create_dir_all(team.join("agents")).unwrap();
1697
+ std::fs::write(
1698
+ team.join("TEAM.md"),
1699
+ "---\nname: addroledoc\nobjective: Add role-doc probe.\nprovider: codex\n---\n\nteam.\n",
1700
+ )
1701
+ .unwrap();
1702
+ std::fs::write(team.join("agents").join("implementer.md"), QS_VALID_ROLE).unwrap();
1703
+ let role_file = team.join("agents").join("worker2.md");
1704
+ std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
1705
+ crate::state::persist::save_runtime_state(
1706
+ &team,
1707
+ &json!({
1708
+ "session_name": "team-addroledoc",
1709
+ "agents": {
1710
+ "implementer": {"status": "running", "provider": "codex", "window": "implementer"}
1711
+ }
1712
+ }),
1713
+ )
1714
+ .unwrap();
1715
+ seed_healthy_coordinator(&team);
1716
+ let transport = OfflineTransport::new();
1717
+
1718
+ let result = add_agent_with_transport(
1719
+ &team,
1720
+ &AgentId::new("worker2"),
1721
+ &role_file,
1722
+ false,
1723
+ None,
1724
+ &transport,
1725
+ );
1726
+
1727
+ assert!(
1728
+ result.is_ok(),
1729
+ "a prepared agents/<id>.md role doc is not membership; runtime state is authoritative: {result:?}"
1730
+ );
1731
+ let state = crate::state::persist::load_runtime_state(&team).expect("load state");
1732
+ assert!(
1733
+ state.pointer("/agents/worker2").is_some(),
1734
+ "add-agent must add worker2 to runtime state; state={state}"
1735
+ );
1736
+ assert_eq!(
1737
+ transport.spawn_records().len(),
1738
+ 1,
1739
+ "add-agent must spawn exactly the new worker"
1740
+ );
1741
+ }
1742
+
1743
+ #[test]
1744
+ fn start_agent_runtime_missing_role_doc_exists_no_side_effect() {
1745
+ let team = temp_ws().join("start_missing_team");
1746
+ std::fs::create_dir_all(team.join("agents")).unwrap();
1747
+ std::fs::write(
1748
+ team.join("TEAM.md"),
1749
+ "---\nname: startmissing\nobjective: Start missing probe.\nprovider: codex\n---\n\nteam.\n",
1750
+ )
1751
+ .unwrap();
1752
+ std::fs::write(team.join("agents").join("reviewer.md"), DELEG_ROLE_WORKER2).unwrap();
1753
+ crate::state::persist::save_runtime_state(
1754
+ &team,
1755
+ &json!({
1756
+ "session_name": "team-startmissing",
1757
+ "agents": {
1758
+ "implementer": {"status": "running", "provider": "codex", "window": "implementer"}
1759
+ }
1760
+ }),
1761
+ )
1762
+ .unwrap();
1763
+ let transport = OfflineTransport::new();
1764
+
1765
+ let result = start_agent_with_transport(
1766
+ &team,
1767
+ &AgentId::new("reviewer"),
1768
+ false,
1769
+ false,
1770
+ true,
1771
+ None,
1772
+ &transport,
1773
+ );
1774
+
1775
+ let text = format!("{result:?}");
1776
+ assert!(
1777
+ text.contains("not found"),
1778
+ "start-agent must refuse runtime-missing agents before side effects; got {text}"
1779
+ );
1780
+ assert!(
1781
+ transport.spawn_records().is_empty(),
1782
+ "runtime-missing start-agent must not spawn a pane"
1783
+ );
1784
+ assert!(
1785
+ !team
1786
+ .join(".team")
1787
+ .join("runtime")
1788
+ .join("mcp")
1789
+ .join("reviewer.json")
1790
+ .exists(),
1791
+ "runtime-missing start-agent must not write worker MCP config"
1792
+ );
1793
+ let events = crate::event_log::EventLog::new(&team)
1794
+ .tail(20)
1795
+ .unwrap_or_default();
1796
+ assert!(
1797
+ !events.iter().any(|event| {
1798
+ event.get("event").and_then(|v| v.as_str()) == Some("start_agent.agent_start")
1799
+ }),
1800
+ "runtime-missing start-agent must not emit start_agent.agent_start; events={events:?}"
1801
+ );
1802
+ }
1803
+
1804
+ #[test]
1805
+ fn restart_allow_fresh_copilot_persists_expected_session_id() {
1806
+ let ws = temp_ws().join("restartcopilot");
1807
+ std::fs::create_dir_all(ws.join("agents")).unwrap();
1808
+ std::fs::write(
1809
+ ws.join("TEAM.md"),
1810
+ "---\nname: restartcopilot\nobjective: Restart copilot probe.\nprovider: copilot\n---\n\nteam.\n",
1811
+ )
1812
+ .unwrap();
1813
+ std::fs::write(
1814
+ ws.join("agents").join("alpha.md"),
1815
+ "---\nname: alpha\nrole: Alpha Worker\nprovider: copilot\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nAlpha.\n",
1816
+ )
1817
+ .unwrap();
1818
+ let spec = crate::compiler::compile_team(&ws).expect("compile copilot team");
1819
+ std::fs::write(ws.join("team.spec.yaml"), crate::model::yaml::dumps(&spec)).unwrap();
1820
+ crate::state::persist::save_runtime_state(
1821
+ &ws,
1822
+ &json!({
1823
+ "session_name": "team-restartcopilot",
1824
+ "agents": {
1825
+ "alpha": {
1826
+ "status": "running",
1827
+ "provider": "copilot",
1828
+ "window": "alpha",
1829
+ "first_send_at": "2026-05-27T10:00:00+00:00",
1830
+ "session_id": null,
1831
+ "rollout_path": "/tmp/stale-copilot-rollout.jsonl",
1832
+ "captured_at": "2026-05-27T10:00:00+00:00",
1833
+ "captured_via": "stale",
1834
+ "attribution_confidence": "stale"
1835
+ }
1836
+ }
1837
+ }),
1838
+ )
1839
+ .unwrap();
1840
+ seed_healthy_coordinator(&ws);
1841
+ let transport = OfflineTransport::new();
1842
+
1843
+ let result = restart_with_transport(&ws, true, None, &transport);
1844
+
1845
+ assert!(
1846
+ matches!(result, Ok(RestartReport::Restarted { .. })),
1847
+ "allow-fresh restart should fresh-spawn the copilot worker; got {result:?}"
1848
+ );
1849
+ let recorded = transport.spawn_records();
1850
+ assert_eq!(
1851
+ recorded.len(),
1852
+ 1,
1853
+ "expected one copilot spawn; got {recorded:?}"
1854
+ );
1855
+ let session_arg = recorded[0]
1856
+ .1
1857
+ .windows(2)
1858
+ .find_map(|pair| (pair[0] == "--session-id").then(|| pair[1].clone()))
1859
+ .expect("copilot fresh spawn argv must include --session-id");
1860
+ let state = crate::state::persist::load_runtime_state(&ws).expect("load state");
1861
+ let agent = state.pointer("/agents/alpha").expect("alpha state");
1862
+ assert_eq!(
1863
+ agent.get("session_id").and_then(serde_json::Value::as_str),
1864
+ Some(session_arg.as_str()),
1865
+ "fresh restart must promote expected_session_id to persisted session_id"
1866
+ );
1867
+ assert_eq!(
1868
+ agent
1869
+ .get("_pending_session_id")
1870
+ .and_then(serde_json::Value::as_str),
1871
+ Some(session_arg.as_str()),
1872
+ "fresh restart must retain pending session audit state"
1873
+ );
1874
+ for stale in [
1875
+ "rollout_path",
1876
+ "captured_at",
1877
+ "captured_via",
1878
+ "attribution_confidence",
1879
+ ] {
1880
+ assert_eq!(
1881
+ agent.get(stale),
1882
+ Some(&serde_json::Value::Null),
1883
+ "fresh restart must clear stale capture field {stale}: {agent}"
1884
+ );
1885
+ }
1886
+ let plan = crate::lifecycle::restart::classify_restart_plan(&state, false)
1887
+ .expect("classify second restart");
1888
+ assert!(
1889
+ plan.unresumable
1890
+ .iter()
1891
+ .all(|worker| worker.reason != "no_persisted_session_id"),
1892
+ "second restart must not loop as no_persisted_session_id; unresumable={:?}",
1893
+ plan.unresumable
1894
+ );
1895
+ }
1896
+
1897
+ #[test]
1898
+ fn add_agent_adaptive_splits_last_non_full_layout_window() {
1899
+ let roles = [("w1.md", role_doc("w1")), ("w2.md", role_doc("w2"))];
1900
+ let role_refs = roles
1901
+ .iter()
1902
+ .map(|(file, doc)| (*file, doc.as_str()))
1903
+ .collect::<Vec<_>>();
1904
+ let team = quick_start_team_dir_with_roles(&role_refs);
1905
+ let session = "team-layout-add";
1906
+ crate::state::persist::save_runtime_state(
1907
+ &team,
1908
+ &json!({
1909
+ "session_name": session,
1910
+ "display_backend": "adaptive",
1911
+ "active_team_key": "teamdir",
1912
+ "agents": {
1913
+ "w1": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 0, "pane_id": "%1"},
1914
+ "w2": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 1, "pane_id": "%2"}
1915
+ }
1916
+ }),
1917
+ )
1918
+ .unwrap();
1919
+ seed_healthy_coordinator(&team);
1920
+ let role_file = team.join("w3-role.md");
1921
+ std::fs::write(&role_file, role_doc("w3")).unwrap();
1922
+ let transport = OfflineTransport::new()
1923
+ .with_session_present(true)
1924
+ .with_targets(vec![
1925
+ layout_pane(session, "team-w1", "%1", 0),
1926
+ layout_pane(session, "team-w1", "%2", 1),
1927
+ ])
1928
+ .with_pane_presence("%1", true)
1929
+ .with_pane_presence("%2", true);
1930
+
1931
+ add_agent_with_transport(
1932
+ &team,
1933
+ &AgentId::new("w3"),
1934
+ &role_file,
1935
+ true,
1936
+ None,
1937
+ &transport,
1938
+ )
1939
+ .expect("adaptive add-agent should start");
1940
+
1941
+ assert_eq!(
1942
+ transport.spawn_window_records().last(),
1943
+ Some(&(String::from("spawn_split"), String::from("team-w1"))),
1944
+ "last non-full layout window should be split"
1945
+ );
1946
+ let (_raw, state) = raw_runtime_state(&team);
1947
+ assert_eq!(state.pointer("/agents/w3/layout_window"), Some(&json!("team-w1")));
1948
+ assert_eq!(state.pointer("/agents/w3/pane_index"), Some(&json!(2)));
1949
+ }
1950
+
1951
+ #[test]
1952
+ fn add_agent_adaptive_creates_suffix_window_when_last_layout_full_and_name_collides() {
1953
+ let roles = [
1954
+ ("w1.md", role_doc("w1")),
1955
+ ("w2.md", role_doc("w2")),
1956
+ ("w3.md", role_doc("w3")),
1957
+ ];
1958
+ let role_refs = roles
1959
+ .iter()
1960
+ .map(|(file, doc)| (*file, doc.as_str()))
1961
+ .collect::<Vec<_>>();
1962
+ let team = quick_start_team_dir_with_roles(&role_refs);
1963
+ let session = "team-layout-add-full";
1964
+ crate::state::persist::save_runtime_state(
1965
+ &team,
1966
+ &json!({
1967
+ "session_name": session,
1968
+ "display_backend": "adaptive",
1969
+ "active_team_key": "teamdir",
1970
+ "agents": {
1971
+ "w1": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 0, "pane_id": "%1"},
1972
+ "w2": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 1, "pane_id": "%2"},
1973
+ "w3": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 2, "pane_id": "%3"}
1974
+ }
1975
+ }),
1976
+ )
1977
+ .unwrap();
1978
+ seed_healthy_coordinator(&team);
1979
+ let role_file = team.join("w4-role.md");
1980
+ std::fs::write(&role_file, role_doc("w4")).unwrap();
1981
+ let transport = OfflineTransport::new()
1982
+ .with_session_present(true)
1983
+ .with_targets(vec![
1984
+ layout_pane(session, "team-w1", "%1", 0),
1985
+ layout_pane(session, "team-w1", "%2", 1),
1986
+ layout_pane(session, "team-w1", "%3", 2),
1987
+ layout_pane(session, "team-w2", "%9", 0),
1988
+ ])
1989
+ .with_pane_presence("%1", true)
1990
+ .with_pane_presence("%2", true)
1991
+ .with_pane_presence("%3", true);
1992
+
1993
+ add_agent_with_transport(
1994
+ &team,
1995
+ &AgentId::new("w4"),
1996
+ &role_file,
1997
+ true,
1998
+ None,
1999
+ &transport,
2000
+ )
2001
+ .expect("adaptive add-agent should start");
2002
+
2003
+ assert_eq!(
2004
+ transport.spawn_window_records().last(),
2005
+ Some(&(String::from("spawn_into"), String::from("team-w2-2"))),
2006
+ "full last layout window should create next window with collision suffix"
2007
+ );
2008
+ let (_raw, state) = raw_runtime_state(&team);
2009
+ assert_eq!(state.pointer("/agents/w4/layout_window"), Some(&json!("team-w2-2")));
2010
+ assert_eq!(state.pointer("/agents/w4/pane_index"), Some(&json!(0)));
2011
+ }
2012
+
2013
+ #[test]
2014
+ fn stop_agent_adaptive_kills_target_pane_not_shared_layout_window() {
2015
+ let roles = [("w1.md", role_doc("w1")), ("w2.md", role_doc("w2"))];
2016
+ let role_refs = roles
2017
+ .iter()
2018
+ .map(|(file, doc)| (*file, doc.as_str()))
2019
+ .collect::<Vec<_>>();
2020
+ let team = quick_start_team_dir_with_roles(&role_refs);
2021
+ let spec = crate::compiler::compile_team(&team).unwrap();
2022
+ std::fs::write(team.join("team.spec.yaml"), crate::model::yaml::dumps(&spec)).unwrap();
2023
+ let session = "team-layout-stop";
2024
+ crate::state::persist::save_runtime_state(
2025
+ &team,
2026
+ &json!({
2027
+ "session_name": session,
2028
+ "display_backend": "adaptive",
2029
+ "agents": {
2030
+ "w1": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 0, "pane_id": "%1"},
2031
+ "w2": {"status": "running", "provider": "codex", "window": "team-w1", "layout_window": "team-w1", "layout_index": 0, "pane_index": 1, "pane_id": "%2"}
2032
+ }
2033
+ }),
2034
+ )
2035
+ .unwrap();
2036
+ let transport = OfflineTransport::new()
2037
+ .with_targets(vec![
2038
+ layout_pane(session, "team-w1", "%1", 0),
2039
+ layout_pane(session, "team-w1", "%2", 1),
2040
+ ])
2041
+ .with_pane_presence("%2", true);
2042
+
2043
+ let report =
2044
+ stop_agent_with_transport(&team, &AgentId::new("w2"), None, &transport).unwrap();
2045
+
2046
+ assert!(report.stopped);
2047
+ assert_eq!(report.target, "%2");
2048
+ assert!(
2049
+ transport.calls().iter().any(|call| *call == "kill_pane"),
2050
+ "adaptive stop must kill only the target pane"
2051
+ );
2052
+ assert!(
2053
+ !transport.calls().iter().any(|call| *call == "kill_window"),
2054
+ "adaptive stop must not kill the shared layout window"
2055
+ );
2056
+ let (_raw, state) = raw_runtime_state(&team);
2057
+ assert_eq!(state.pointer("/agents/w1/pane_id"), Some(&json!("%1")));
2058
+ assert_eq!(state.pointer("/agents/w1/layout_window"), Some(&json!("team-w1")));
2059
+ }
2060
+
2061
+ #[test]
2062
+ fn start_agent_adaptive_restarts_missing_pane_in_existing_layout_window() {
2063
+ let ws = temp_ws();
2064
+ crate::state::persist::save_runtime_state(
2065
+ &ws,
2066
+ &json!({
2067
+ "session_name": "team-layout-restart",
2068
+ "display_backend": "adaptive",
2069
+ "agents": {
2070
+ "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"},
2071
+ "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"}
2072
+ }
2073
+ }),
2074
+ )
2075
+ .unwrap();
2076
+ seed_healthy_coordinator(&ws);
2077
+ let transport = OfflineTransport::new()
2078
+ .with_session_present(true)
2079
+ .with_targets(vec![layout_pane("team-layout-restart", "team-w1", "%2", 1)])
2080
+ .with_pane_presence("%1", false)
2081
+ .with_pane_presence("%2", true);
2082
+
2083
+ let outcome = start_agent_with_transport(
2084
+ &ws,
2085
+ &AgentId::new("w1"),
2086
+ false,
2087
+ true,
2088
+ true,
2089
+ None,
2090
+ &transport,
2091
+ )
2092
+ .expect("adaptive restart should spawn");
2093
+
2094
+ assert!(matches!(outcome, StartAgentOutcome::Running { .. }));
2095
+ assert_eq!(
2096
+ transport.spawn_window_records().last(),
2097
+ Some(&(String::from("spawn_split"), String::from("team-w1"))),
2098
+ "missing worker pane should be rebuilt in its existing layout window"
2099
+ );
2100
+ let (_raw, state) = raw_runtime_state(&ws);
2101
+ assert_eq!(state.pointer("/agents/w1/layout_window"), Some(&json!("team-w1")));
2102
+ assert_eq!(state.pointer("/agents/w1/window"), Some(&json!("team-w1")));
2103
+ assert_eq!(state.pointer("/agents/w2/pane_id"), Some(&json!("%2")));
2104
+ }
2105
+
1456
2106
  // ═════════════════════════════════════════════════════════════════════════
1457
2107
  // WAVE 2 · LANE A — agent-lifecycle byte-parity contracts. stop_agent / reset_agent / remove_agent /
1458
2108
  // fork_agent are stubs (OwnerRefused / RequirementUnmet / "session_id missing"). These RED contracts
@@ -1592,8 +2242,8 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
1592
2242
  );
1593
2243
  assert_eq!(
1594
2244
  state["display_backend"],
1595
- json!("none"),
1596
- "golden resolve_display_backend(None, source='launch') defaults to none"
2245
+ json!("adaptive"),
2246
+ "adaptive layout directive: resolve_display_backend(None, source='launch') defaults to adaptive"
1597
2247
  );
1598
2248
  }
1599
2249
 
@@ -1640,11 +2290,15 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
1640
2290
  "captured_at",
1641
2291
  "captured_via",
1642
2292
  "attribution_confidence",
2293
+ "layout_window",
2294
+ "layout_index",
2295
+ "pane_index",
2296
+ "display",
1643
2297
  "spawn_cwd",
1644
2298
  "spawned_at",
1645
2299
  "pane_id",
1646
2300
  ],
1647
- "running agent state key order must match golden launch/core.py:238-255; raw={raw}"
2301
+ "running agent state key order must include adaptive layout fields; raw={raw}"
1648
2302
  );
1649
2303
  assert_eq!(agent["status"], json!("running"));
1650
2304
  assert_eq!(agent["provider"], json!("codex"));
@@ -1652,7 +2306,7 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
1652
2306
  assert_eq!(agent["model"], json!("gpt-5.5"));
1653
2307
  assert_eq!(agent["auth_mode"], json!("subscription"));
1654
2308
  assert!(agent["profile"].is_null());
1655
- assert_eq!(agent["window"], json!("implementer"));
2309
+ assert_eq!(agent["window"], json!("team-w1"));
1656
2310
  assert_eq!(
1657
2311
  agent["mcp_config"],
1658
2312
  json!(workspace
@@ -1674,6 +2328,12 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
1674
2328
  assert!(agent["captured_at"].is_null());
1675
2329
  assert!(agent["captured_via"].is_null());
1676
2330
  assert!(agent["attribution_confidence"].is_null());
2331
+ assert_eq!(agent["layout_window"], json!("team-w1"));
2332
+ assert_eq!(agent["layout_index"], json!(0));
2333
+ assert_eq!(agent["pane_index"], json!(0));
2334
+ assert_eq!(agent.pointer("/display/backend"), Some(&json!("adaptive")));
2335
+ assert_eq!(agent.pointer("/display/window"), Some(&json!("team-w1")));
2336
+ assert_eq!(agent.pointer("/display/pane_id"), Some(&json!("%0")));
1677
2337
  // D5 (#264) / Python launch/core.py:253 — fresh launch persists spawn_cwd=workspace.
1678
2338
  assert_eq!(agent["spawn_cwd"], json!(workspace.to_string_lossy()));
1679
2339
  assert_eq!(agent["spawned_at"], json!(FIXED_SPAWNED_AT));