@team-agent/installer 0.3.12 → 0.3.14

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 (35) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +23 -0
  4. package/crates/team-agent/src/cli/leader.rs +2 -5
  5. package/crates/team-agent/src/cli/mod.rs +258 -79
  6. package/crates/team-agent/src/cli/tests/compile.rs +69 -0
  7. package/crates/team-agent/src/cli/tests/main_preserved.rs +68 -0
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +160 -2
  9. package/crates/team-agent/src/compiler.rs +30 -0
  10. package/crates/team-agent/src/coordinator/tick.rs +16 -83
  11. package/crates/team-agent/src/leader/lease.rs +4 -20
  12. package/crates/team-agent/src/leader/mod.rs +3 -1
  13. package/crates/team-agent/src/leader/owner_bind.rs +46 -48
  14. package/crates/team-agent/src/leader/provider_attribution.rs +214 -0
  15. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -3
  16. package/crates/team-agent/src/leader/rediscover.rs +34 -17
  17. package/crates/team-agent/src/leader/start.rs +279 -4
  18. package/crates/team-agent/src/lifecycle/launch.rs +279 -24
  19. package/crates/team-agent/src/lifecycle/restart/common.rs +50 -40
  20. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +343 -49
  21. package/crates/team-agent/src/lifecycle/restart/selection.rs +9 -6
  22. package/crates/team-agent/src/lifecycle/tests/core.rs +181 -45
  23. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +390 -61
  24. package/crates/team-agent/src/lifecycle/types.rs +28 -0
  25. package/crates/team-agent/src/messaging/delivery.rs +5 -1
  26. package/crates/team-agent/src/messaging/helpers.rs +1 -1
  27. package/crates/team-agent/src/messaging/tests/runtime.rs +14 -0
  28. package/crates/team-agent/src/provider/adapter.rs +6 -3
  29. package/crates/team-agent/src/provider/startup_prompt.rs +173 -0
  30. package/crates/team-agent/src/provider/testdata/copilot-ready-marker.txt +5 -0
  31. package/crates/team-agent/src/provider/testdata/copilot-trust-prompt.txt +19 -0
  32. package/crates/team-agent/src/tmux_backend.rs +5 -0
  33. package/crates/team-agent/src/transport/test_support.rs +22 -7
  34. package/crates/team-agent/src/transport.rs +4 -0
  35. package/package.json +4 -4
@@ -121,12 +121,23 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
121
121
 
122
122
  let transport = OfflineTransport::new();
123
123
  let result = quick_start_with_transport(&team, None, true, true, None, &transport);
124
- assert!(matches!(result, Ok(QuickStartReport::Ready { .. })), "quick-start failed: {result:?}");
124
+ assert!(
125
+ matches!(result, Ok(QuickStartReport::Ready { .. })),
126
+ "quick-start failed: {result:?}"
127
+ );
125
128
 
126
129
  let state_path = crate::state::persist::runtime_state_path(&workspace);
127
- assert!(state_path.exists(), "quick-start .team/current must persist runtime state under project root");
128
130
  assert!(
129
- !workspace.join(".team").join(".team").join("runtime").join("state.json").exists(),
131
+ state_path.exists(),
132
+ "quick-start .team/current must persist runtime state under project root"
133
+ );
134
+ assert!(
135
+ !workspace
136
+ .join(".team")
137
+ .join(".team")
138
+ .join("runtime")
139
+ .join("state.json")
140
+ .exists(),
130
141
  "quick-start .team/current must not create nested .team/.team runtime state"
131
142
  );
132
143
 
@@ -137,11 +148,21 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
137
148
  crate::state::selector::SelectorMode::RuntimeOnly,
138
149
  )
139
150
  .expect("status/collect selector should resolve project root");
140
- assert_eq!(selected.run_workspace, workspace, "input={}", input.display());
151
+ assert_eq!(
152
+ selected.run_workspace,
153
+ workspace,
154
+ "input={}",
155
+ input.display()
156
+ );
141
157
  // E5: selector resolves spec_path to .team/runtime/<team_key>/ (team_key="current").
142
158
  let expected_spec = crate::model::paths::runtime_spec_path(&workspace, "current");
143
159
  assert_eq!(
144
- selected.spec_path.as_deref().map(std::fs::canonicalize).transpose().unwrap(),
160
+ selected
161
+ .spec_path
162
+ .as_deref()
163
+ .map(std::fs::canonicalize)
164
+ .transpose()
165
+ .unwrap(),
145
166
  Some(std::fs::canonicalize(&expected_spec).unwrap()),
146
167
  "input={}",
147
168
  input.display()
@@ -155,14 +176,20 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
155
176
  summary: false,
156
177
  json: true,
157
178
  });
158
- assert!(status.is_ok(), "status should normalize teamdir to project-root runtime state: {status:?}");
179
+ assert!(
180
+ status.is_ok(),
181
+ "status should normalize teamdir to project-root runtime state: {status:?}"
182
+ );
159
183
 
160
184
  let collect = crate::cli::cmd_collect(&crate::cli::CollectArgs {
161
185
  result_file: None,
162
186
  workspace: team.clone(),
163
187
  json: true,
164
188
  });
165
- assert!(collect.is_ok(), "collect should normalize teamdir to the same project-root state/spec: {collect:?}");
189
+ assert!(
190
+ collect.is_ok(),
191
+ "collect should normalize teamdir to the same project-root state/spec: {collect:?}"
192
+ );
166
193
  }
167
194
 
168
195
  // P0 — quick_start over an INVALID role doc (missing `provider`) must surface the REAL compile
@@ -222,7 +249,15 @@ fn launch_dry_run_resolves_real_plan_not_stub_error() {
222
249
  fn start_agent_proceeds_past_owner_gate_for_unowned_workspace() {
223
250
  let ws = unowned_running_ws();
224
251
  let transport = OfflineTransport::new();
225
- let result = start_agent_with_transport(&ws, &AgentId::new("w1"), false, false, true, None, &transport);
252
+ let result = start_agent_with_transport(
253
+ &ws,
254
+ &AgentId::new("w1"),
255
+ false,
256
+ false,
257
+ true,
258
+ None,
259
+ &transport,
260
+ );
226
261
  assert!(
227
262
  !matches!(result, Err(LifecycleError::OwnerRefused(_))),
228
263
  "start_agent must reach the real chain for a no-owner workspace, not hard-refuse owner; got {result:?}"
@@ -292,7 +327,10 @@ fn fork_agent_proceeds_past_owner_gate_for_unowned_workspace() {
292
327
  fn quick_start_full_ready_real_spawn() {
293
328
  let team = quick_start_team_dir(QS_VALID_ROLE);
294
329
  let report = quick_start(&team, None, true, true, None).expect("quick_start launches the team");
295
- assert!(matches!(report, QuickStartReport::Ready { .. }), "got {report:?}");
330
+ assert!(
331
+ matches!(report, QuickStartReport::Ready { .. }),
332
+ "got {report:?}"
333
+ );
296
334
  }
297
335
 
298
336
  // ═════════════════════════════════════════════════════════════════════════
@@ -375,7 +413,9 @@ fn compiled_spec_path_with_paused_agent(team: &std::path::Path) -> PathBuf {
375
413
  _ => None,
376
414
  })
377
415
  .expect("compiled spec must contain agents");
378
- let first = agents.first_mut().expect("compiled spec must contain an agent");
416
+ let first = agents
417
+ .first_mut()
418
+ .expect("compiled spec must contain an agent");
379
419
  let crate::model::yaml::Value::Map(agent) = first else {
380
420
  panic!("compiled agent must be a map");
381
421
  };
@@ -422,7 +462,10 @@ fn spine_launch_dry_run_safety_reflects_spec_dangerous() {
422
462
  let team = quick_start_team_dir_custom(QS_TEAM_MD_DANGEROUS, QS_VALID_ROLE);
423
463
  let spec_path = compiled_spec_path(&team);
424
464
  let report = launch(&spec_path, true, false, true).expect("dry_run launch");
425
- assert!(report.safety.enabled, "safety must reflect spec.runtime.dangerous_auto_approve=true");
465
+ assert!(
466
+ report.safety.enabled,
467
+ "safety must reflect spec.runtime.dangerous_auto_approve=true"
468
+ );
426
469
  assert_eq!(
427
470
  report.safety.source,
428
471
  DangerousApprovalSource::RuntimeConfig,
@@ -471,7 +514,14 @@ fn spine_add_agent_rejects_duplicate_agent_id() {
471
514
  let role = team.join("dup-role.md");
472
515
  std::fs::write(&role, QS_VALID_ROLE).unwrap(); // a role doc named 'implementer' = duplicate
473
516
  let transport = OfflineTransport::new();
474
- let result = add_agent_with_transport(&team, &AgentId::new("implementer"), &role, false, None, &transport);
517
+ let result = add_agent_with_transport(
518
+ &team,
519
+ &AgentId::new("implementer"),
520
+ &role,
521
+ false,
522
+ None,
523
+ &transport,
524
+ );
475
525
  let text = format!("{result:?}");
476
526
  assert!(
477
527
  text.contains("already exists"),
@@ -524,7 +574,7 @@ fn e5_add_agent_does_not_copy_role_into_platform_dir_and_injects_into_spec() {
524
574
  #[test]
525
575
  fn e5_restart_rebuilds_runtime_spec_from_role_docs() {
526
576
  let ws = restart_ws_two_resumable_workers(); // has TEAM.md + agents/{alpha,bravo}.md + state
527
- // Edit a role doc AFTER initial compile: change alpha's role text.
577
+ // Edit a role doc AFTER initial compile: change alpha's role text.
528
578
  std::fs::write(
529
579
  ws.join("agents").join("alpha.md"),
530
580
  "---\nname: alpha\nrole: RENAMED Alpha Role\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nAlpha edited.\n",
@@ -551,10 +601,13 @@ fn e5_restart_rebuilds_runtime_spec_from_role_docs() {
551
601
  #[test]
552
602
  fn red2_restart_finds_runtime_spec_not_missing_when_user_dir_has_no_spec() {
553
603
  let ws = restart_ws_two_resumable_workers(); // has TEAM.md + agents + state
554
- // Simulate spec-demote: remove any user-dir spec the fixture wrote; restart must still find
555
- // the runtime spec (rebuilt from role docs / read-order B), NOT report "missing spec".
604
+ // Simulate spec-demote: remove any user-dir spec the fixture wrote; restart must still find
605
+ // the runtime spec (rebuilt from role docs / read-order B), NOT report "missing spec".
556
606
  let _ = std::fs::remove_file(ws.join("team.spec.yaml"));
557
- assert!(!ws.join("team.spec.yaml").exists(), "user-dir spec removed (spec-demote condition)");
607
+ assert!(
608
+ !ws.join("team.spec.yaml").exists(),
609
+ "user-dir spec removed (spec-demote condition)"
610
+ );
558
611
  let transport = OfflineTransport::new();
559
612
  let result = restart_with_transport(&ws, false, None, &transport);
560
613
  // The gate must NOT short-circuit with "missing spec for restart" — restart must proceed
@@ -575,7 +628,10 @@ fn red2_restart_empty_workspace_still_reports_missing() {
575
628
  let result = restart_with_transport(&ws, false, None, &transport);
576
629
  let text = format!("{result:?}");
577
630
  assert!(
578
- text.contains("missing spec") || text.to_lowercase().contains("not found") || text.contains("no_local_team_context") || text.to_lowercase().contains("invalid workspace"),
631
+ text.contains("missing spec")
632
+ || text.to_lowercase().contains("not found")
633
+ || text.contains("no_local_team_context")
634
+ || text.to_lowercase().contains("invalid workspace"),
579
635
  "RED-2 negative: empty workspace restart must still error (missing/not-found); got {text}"
580
636
  );
581
637
  let _ = std::fs::remove_dir_all(&ws);
@@ -595,7 +651,11 @@ fn red2still_entry_gate_resolves_parent_dot_team_form_a() {
595
651
  let base = temp_ws();
596
652
  let team_dir = base.join("teamdir");
597
653
  std::fs::create_dir_all(team_dir.join("agents")).unwrap();
598
- std::fs::write(team_dir.join("TEAM.md"), "---\nname: teamdir\nobjective: o\nprovider: codex\n---\nt.\n").unwrap();
654
+ std::fs::write(
655
+ team_dir.join("TEAM.md"),
656
+ "---\nname: teamdir\nobjective: o\nprovider: codex\n---\nt.\n",
657
+ )
658
+ .unwrap();
599
659
  std::fs::write(team_dir.join("agents").join("w1.md"), QS_VALID_ROLE).unwrap();
600
660
  // .team lives in the PARENT (base), with a runtime spec — mirrors quick-start <dir> from base cwd.
601
661
  let spec = crate::compiler::compile_team(&team_dir).unwrap();
@@ -622,7 +682,10 @@ fn red2still_entry_gate_resolves_parent_dot_team_form_a() {
622
682
  );
623
683
  // Full restart on team_dir must NOT early-exit with missing spec.
624
684
  let transport = OfflineTransport::new();
625
- let text = format!("{:?}", restart_with_transport(&team_dir, false, None, &transport));
685
+ let text = format!(
686
+ "{:?}",
687
+ restart_with_transport(&team_dir, false, None, &transport)
688
+ );
626
689
  assert!(
627
690
  !text.contains("missing spec for restart") && !text.contains("active team spec not found"),
628
691
  "RED-2-STILL Form A: restart <team_dir> (.team in parent) must not report missing; got {text}"
@@ -635,7 +698,11 @@ fn red2still_entry_gate_same_dir_dot_team_form_b() {
635
698
  // Form B: .team in team_dir itself (cwd == team_dir at quick-start time).
636
699
  let team_dir = temp_ws();
637
700
  std::fs::create_dir_all(team_dir.join("agents")).unwrap();
638
- std::fs::write(team_dir.join("TEAM.md"), "---\nname: tb\nobjective: o\nprovider: codex\n---\nt.\n").unwrap();
701
+ std::fs::write(
702
+ team_dir.join("TEAM.md"),
703
+ "---\nname: tb\nobjective: o\nprovider: codex\n---\nt.\n",
704
+ )
705
+ .unwrap();
639
706
  std::fs::write(team_dir.join("agents").join("w1.md"), QS_VALID_ROLE).unwrap();
640
707
  let spec = crate::compiler::compile_team(&team_dir).unwrap();
641
708
  let runtime_spec = crate::model::paths::runtime_spec_path(&team_dir, "tb");
@@ -649,7 +716,10 @@ fn red2still_entry_gate_same_dir_dot_team_form_b() {
649
716
  )
650
717
  .unwrap();
651
718
  let transport = OfflineTransport::new();
652
- let text = format!("{:?}", restart_with_transport(&team_dir, false, None, &transport));
719
+ let text = format!(
720
+ "{:?}",
721
+ restart_with_transport(&team_dir, false, None, &transport)
722
+ );
653
723
  assert!(
654
724
  !text.contains("missing spec for restart") && !text.contains("active team spec not found"),
655
725
  "RED-2-STILL Form B: restart <team_dir> (.team in team_dir) must not report missing; got {text}"
@@ -703,7 +773,8 @@ fn e5_add_agent_resolves_team_dir_to_role_dir_when_runtime_spec_exists() {
703
773
  )
704
774
  .unwrap();
705
775
  let transport = OfflineTransport::new();
706
- let result = add_agent_with_transport(&team, &AgentId::new("w2"), &role, false, None, &transport);
776
+ let result =
777
+ add_agent_with_transport(&team, &AgentId::new("w2"), &role, false, None, &transport);
707
778
  // Core decoupling signature: must NOT fail because compile_team looked for TEAM.md/agents under
708
779
  // the runtime spec dir. Before the fix, team_dir=spec_workspace(runtime) → "missing TEAM.md".
709
780
  let text = format!("{result:?}");
@@ -825,15 +896,29 @@ fn launch_with_transport_records_one_spawn_per_agent_carrying_build_command() {
825
896
  "exactly one spawn per started agent; recorded={recorded:?} started={:?}",
826
897
  report.started
827
898
  );
828
- assert!(!report.started.is_empty(), "a real (non-dry-run) launch must spawn >=1 worker");
829
- assert!(!report.dry_run, "launch_with_transport(dry_run=false) must report dry_run=false");
830
- assert_eq!(recorded[0].0, "spawn_first", "the first worker uses new-session (spawn_first)");
831
899
  assert!(
832
- recorded.iter().any(|(_, argv)| argv.iter().any(|a| a == "codex")),
900
+ !report.started.is_empty(),
901
+ "a real (non-dry-run) launch must spawn >=1 worker"
902
+ );
903
+ assert!(
904
+ !report.dry_run,
905
+ "launch_with_transport(dry_run=false) must report dry_run=false"
906
+ );
907
+ assert_eq!(
908
+ recorded[0].0, "spawn_first",
909
+ "the first worker uses new-session (spawn_first)"
910
+ );
911
+ assert!(
912
+ recorded
913
+ .iter()
914
+ .any(|(_, argv)| argv.iter().any(|a| a == "codex")),
833
915
  "each spawn argv must carry the agent's provider build_command (codex); got {recorded:?}"
834
916
  );
835
917
  assert!(
836
- report.started.iter().any(|s| s.agent_id.as_str() == "implementer"),
918
+ report
919
+ .started
920
+ .iter()
921
+ .any(|s| s.agent_id.as_str() == "implementer"),
837
922
  "LaunchReport.started must list the compiled agent; got {:?}",
838
923
  report.started
839
924
  );
@@ -853,8 +938,17 @@ pub(super) fn seed_healthy_coordinator(workspace: &std::path::Path) {
853
938
  std::fs::create_dir_all(crate::model::paths::runtime_dir(workspace)).unwrap();
854
939
  let _ = crate::message_store::MessageStore::open(workspace).unwrap(); // create db schema (schema.ok)
855
940
  let me = crate::coordinator::Pid::new(std::process::id());
856
- crate::coordinator::write_coordinator_metadata(&wp, me, crate::coordinator::MetadataSource::Boot).unwrap();
857
- std::fs::write(crate::coordinator::coordinator_pid_path(&wp), me.to_string()).unwrap();
941
+ crate::coordinator::write_coordinator_metadata(
942
+ &wp,
943
+ me,
944
+ crate::coordinator::MetadataSource::Boot,
945
+ )
946
+ .unwrap();
947
+ std::fs::write(
948
+ crate::coordinator::coordinator_pid_path(&wp),
949
+ me.to_string(),
950
+ )
951
+ .unwrap();
858
952
  }
859
953
 
860
954
  // RED — quick_start_with_transport must drive the REAL spawn path: launch.dry_run==false, started
@@ -889,7 +983,10 @@ fn quick_start_with_transport_spawns_workers_not_dry_run() {
889
983
  launch.started
890
984
  );
891
985
  assert!(
892
- launch.started.iter().any(|s| s.agent_id.as_str() == "implementer"),
986
+ launch
987
+ .started
988
+ .iter()
989
+ .any(|s| s.agent_id.as_str() == "implementer"),
893
990
  "launch.started must list the compiled agent 'implementer'; got {:?}",
894
991
  launch.started
895
992
  );
@@ -899,9 +996,14 @@ fn quick_start_with_transport_spawns_workers_not_dry_run() {
899
996
  !recorded.is_empty(),
900
997
  "quick_start must drive the transport spawn path (>=1 spawn recorded); the dry-run bug records ZERO spawns"
901
998
  );
902
- assert_eq!(recorded[0].0, "spawn_first", "the first worker uses new-session (spawn_first); got {recorded:?}");
999
+ assert_eq!(
1000
+ recorded[0].0, "spawn_first",
1001
+ "the first worker uses new-session (spawn_first); got {recorded:?}"
1002
+ );
903
1003
  assert!(
904
- recorded.iter().any(|(_, argv)| argv.iter().any(|a| a == "codex")),
1004
+ recorded
1005
+ .iter()
1006
+ .any(|(_, argv)| argv.iter().any(|a| a == "codex")),
905
1007
  "the spawn argv must carry the agent's provider build_command (codex); got {recorded:?}"
906
1008
  );
907
1009
  }
@@ -917,7 +1019,10 @@ fn quick_start_fresh_ws_spawns_resident_tmux_and_coordinator() {
917
1019
  let report = quick_start(&team, None, true, true, None).expect("quick_start");
918
1020
  match report {
919
1021
  QuickStartReport::Ready { launch, .. } => {
920
- assert!(!launch.dry_run, "a real quick_start must spawn (not dry-run)");
1022
+ assert!(
1023
+ !launch.dry_run,
1024
+ "a real quick_start must spawn (not dry-run)"
1025
+ );
921
1026
  assert!(
922
1027
  !launch.started.is_empty(),
923
1028
  "a real quick_start must spawn >=1 worker into a live tmux session; got {:?}",
@@ -949,7 +1054,11 @@ pub(super) fn restart_ws_two_resumable_workers() -> PathBuf {
949
1054
  let bravo_rollout = ws.join("bravo-rollout.jsonl");
950
1055
  std::fs::write(&alpha_rollout, "{}\n").unwrap();
951
1056
  std::fs::write(&bravo_rollout, "{}\n").unwrap();
952
- std::fs::write(ws.join("TEAM.md"), "---\nname: restartteam\nobjective: Restart probe.\nprovider: codex\n---\n\nteam.\n").unwrap();
1057
+ std::fs::write(
1058
+ ws.join("TEAM.md"),
1059
+ "---\nname: restartteam\nobjective: Restart probe.\nprovider: codex\n---\n\nteam.\n",
1060
+ )
1061
+ .unwrap();
953
1062
  std::fs::write(ws.join("agents").join("alpha.md"), DELEG_ROLE_ALPHA).unwrap();
954
1063
  std::fs::write(ws.join("agents").join("bravo.md"), DELEG_ROLE_BRAVO).unwrap();
955
1064
  let spec = crate::compiler::compile_team(&ws).expect("compile 2-agent team");
@@ -1030,24 +1139,186 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
1030
1139
  matches!(result, Ok(RestartReport::Restarted { coordinator_started: true, .. })),
1031
1140
  "restart must reach RestartReport::Restarted with coordinator_started=true (AlreadyRunning, seeded); got {result:?}"
1032
1141
  );
1142
+ let events = crate::event_log::EventLog::new(&ws).tail(20).unwrap();
1143
+ let completed = events
1144
+ .iter()
1145
+ .find(|event| event.get("event").and_then(|v| v.as_str()) == Some("restart.completed"))
1146
+ .expect("restart.completed event for successful restart");
1147
+ assert_eq!(completed.get("rc").and_then(|v| v.as_str()), Some("ok"));
1033
1148
 
1034
1149
  let ws = restart_ws_two_resumable_workers();
1035
1150
  let dead_after_first = OfflineTransport::new().with_session_absent_after_spawn_first();
1036
- let result = restart_with_transport(&ws, false, None, &dead_after_first);
1151
+ let result = restart_with_transport_with_readiness_deadline(
1152
+ &ws,
1153
+ false,
1154
+ None,
1155
+ &dead_after_first,
1156
+ Some(0),
1157
+ );
1037
1158
  let recorded = dead_after_first.spawn_records();
1038
1159
  assert_eq!(
1039
1160
  recorded.len(),
1040
1161
  1,
1041
- "if the session disappears after alpha, restart must stop before spawning bravo; got result={result:?} recorded={recorded:?}"
1162
+ "if a resumed session disappears after alpha, restart must fail fast before bravo; got result={result:?} recorded={recorded:?}"
1042
1163
  );
1043
1164
  assert!(
1044
- recorded.iter().all(|(kind, _)| kind != "spawn_into"),
1045
- "restart must not call spawn_into/new-window for bravo against a dead server; result={result:?} recorded={recorded:?}"
1165
+ recorded.iter().all(|(kind, _)| kind == "spawn_first"),
1166
+ "restart must not call spawn_into/new-window against a dead server; result={result:?} recorded={recorded:?}"
1046
1167
  );
1047
- let error = format!("{result:?}");
1168
+ let result_debug = format!("{result:?}");
1169
+ let report = result.expect("resume integrity failure should return a typed failed report");
1170
+ let RestartReport::Failed { failed_agents, .. } = report else {
1171
+ panic!("resume integrity failure must not be Partial/Restarted; got {report:?}");
1172
+ };
1173
+ assert_eq!(failed_agents.len(), 1);
1174
+ assert_eq!(failed_agents[0].agent_id.as_str(), "alpha");
1175
+ assert_eq!(failed_agents[0].phase, "resume");
1048
1176
  assert!(
1049
- error.contains("session_disappeared_after_spawn") || error.contains("provider_resume_exited"),
1050
- "restart must report the first resumed agent/session disappearance explicitly, not continue to a later no-server failure; result={result:?}"
1177
+ dead_after_first.calls().iter().all(|call| *call != "kill_session"),
1178
+ "a single worker/session disappearance must not trigger another shared-session kill; calls={:?}",
1179
+ dead_after_first.calls()
1180
+ );
1181
+ let events = crate::event_log::EventLog::new(&ws).tail(20).unwrap();
1182
+ let failed = events
1183
+ .iter()
1184
+ .find(|event| event.get("event").and_then(|v| v.as_str()) == Some("restart.agent_failed"))
1185
+ .expect("restart.agent_failed event for isolated alpha");
1186
+ assert_eq!(
1187
+ failed.get("agent_id").and_then(|v| v.as_str()),
1188
+ Some("alpha")
1189
+ );
1190
+ assert!(
1191
+ failed
1192
+ .get("error")
1193
+ .and_then(|v| v.as_str())
1194
+ .is_some_and(|error| error.contains("session_disappeared_after_spawn")),
1195
+ "restart must report the first resumed agent/session disappearance explicitly; event={failed} result={result_debug}"
1196
+ );
1197
+ }
1198
+
1199
+ #[test]
1200
+ fn restart_spawn_failure_isolated_to_partial_report() {
1201
+ let ws = restart_ws_two_resumable_workers();
1202
+ let transport = OfflineTransport::new().with_spawn_failure("bravo", "injected bravo failure");
1203
+
1204
+ let result = restart_with_transport(&ws, false, None, &transport);
1205
+
1206
+ let report = result.expect("single worker failure must be represented as partial report");
1207
+ let RestartReport::Partial {
1208
+ agents,
1209
+ failed_agents,
1210
+ coordinator_started,
1211
+ ..
1212
+ } = report
1213
+ else {
1214
+ panic!("single worker failure must return RestartReport::Partial; got {report:?}");
1215
+ };
1216
+ assert!(
1217
+ coordinator_started,
1218
+ "partial restart still starts coordinator for successful agents"
1219
+ );
1220
+ assert_eq!(
1221
+ agents
1222
+ .iter()
1223
+ .map(|agent| agent.agent_id.as_str())
1224
+ .collect::<Vec<_>>(),
1225
+ vec!["alpha"]
1226
+ );
1227
+ assert_eq!(failed_agents.len(), 1);
1228
+ assert_eq!(failed_agents[0].agent_id.as_str(), "bravo");
1229
+ assert_eq!(failed_agents[0].phase, "spawn");
1230
+
1231
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
1232
+ assert_eq!(
1233
+ state
1234
+ .pointer("/agents/alpha/status")
1235
+ .and_then(|v| v.as_str()),
1236
+ Some("running")
1237
+ );
1238
+ assert_eq!(
1239
+ state
1240
+ .pointer("/agents/bravo/status")
1241
+ .and_then(|v| v.as_str()),
1242
+ Some("failed")
1243
+ );
1244
+ assert!(
1245
+ state
1246
+ .pointer("/agents/bravo/restart_error")
1247
+ .and_then(|v| v.as_str())
1248
+ .is_some_and(|error| error.contains("injected bravo failure")),
1249
+ "failed agent must retain restart_error in state: {state}"
1250
+ );
1251
+
1252
+ let events = crate::event_log::EventLog::new(&ws).tail(20).unwrap();
1253
+ let completed = events
1254
+ .iter()
1255
+ .find(|event| event.get("event").and_then(|v| v.as_str()) == Some("restart.completed"))
1256
+ .expect("restart.completed event for partial restart");
1257
+ assert_eq!(
1258
+ completed.get("rc").and_then(|v| v.as_str()),
1259
+ Some("partial")
1260
+ );
1261
+ assert!(
1262
+ completed
1263
+ .get("successful_agents")
1264
+ .and_then(|v| v.as_array())
1265
+ .is_some_and(|agents| agents.iter().any(|agent| agent.as_str() == Some("alpha"))),
1266
+ "completed event must list successful agents: {completed}"
1267
+ );
1268
+ assert!(
1269
+ completed
1270
+ .get("failed_agents")
1271
+ .and_then(|v| v.as_array())
1272
+ .is_some_and(|agents| agents.iter().any(|agent| {
1273
+ agent.get("agent_id").and_then(|v| v.as_str()) == Some("bravo")
1274
+ && agent.get("phase").and_then(|v| v.as_str()) == Some("spawn")
1275
+ })),
1276
+ "completed event must list failed agents with phase: {completed}"
1277
+ );
1278
+ }
1279
+
1280
+ #[test]
1281
+ fn restart_all_spawn_failures_return_failed_report() {
1282
+ let ws = restart_ws_one_resumable_worker();
1283
+ let transport = OfflineTransport::new().with_spawn_failure("alpha", "injected alpha failure");
1284
+
1285
+ let result = restart_with_transport(&ws, false, None, &transport);
1286
+
1287
+ let report = result.expect("all worker failures should return typed failed report");
1288
+ let RestartReport::Failed { failed_agents, .. } = report else {
1289
+ panic!("all worker failures must return RestartReport::Failed; got {report:?}");
1290
+ };
1291
+ assert_eq!(failed_agents.len(), 1);
1292
+ assert_eq!(failed_agents[0].agent_id.as_str(), "alpha");
1293
+ assert_eq!(failed_agents[0].phase, "spawn");
1294
+
1295
+ let events = crate::event_log::EventLog::new(&ws).tail(20).unwrap();
1296
+ let completed = events
1297
+ .iter()
1298
+ .find(|event| event.get("event").and_then(|v| v.as_str()) == Some("restart.completed"))
1299
+ .expect("restart.completed event for failed restart");
1300
+ assert_eq!(completed.get("rc").and_then(|v| v.as_str()), Some("fail"));
1301
+ }
1302
+
1303
+ #[test]
1304
+ fn restart_spawn_loop_keeps_failure_isolation_guard() {
1305
+ let source = include_str!("../restart/rebuild.rs");
1306
+ let start = source
1307
+ .find("BEGIN_B5_RESTART_ISOLATION_LOOP")
1308
+ .expect("B5 isolation loop start marker");
1309
+ let end = source
1310
+ .find("END_B5_RESTART_ISOLATION_LOOP")
1311
+ .expect("B5 isolation loop end marker");
1312
+ let body = &source[start..end];
1313
+ for forbidden in ["?;", "break", "return "] {
1314
+ assert!(
1315
+ !body.contains(forbidden),
1316
+ "B5 spawn loop must not fail-fast via {forbidden:?}; body={body}"
1317
+ );
1318
+ }
1319
+ assert!(
1320
+ body.contains("continue"),
1321
+ "B5 spawn loop should isolate per-agent failures and continue; body={body}"
1051
1322
  );
1052
1323
  }
1053
1324
 
@@ -1074,10 +1345,14 @@ fn restart_times_out_when_spawned_worker_pane_is_not_addressable() {
1074
1345
  let events = crate::event_log::EventLog::new(&ws).tail(20).unwrap();
1075
1346
  let timeout = events
1076
1347
  .iter()
1077
- .find(|event| event.get("event").and_then(|v| v.as_str()) == Some("restart.readiness_timeout"))
1348
+ .find(|event| {
1349
+ event.get("event").and_then(|v| v.as_str()) == Some("restart.readiness_timeout")
1350
+ })
1078
1351
  .expect("restart.readiness_timeout event");
1079
1352
  assert_eq!(
1080
- timeout.get("worker_pane_addressable").and_then(|v| v.as_bool()),
1353
+ timeout
1354
+ .get("worker_pane_addressable")
1355
+ .and_then(|v| v.as_bool()),
1081
1356
  Some(false),
1082
1357
  "timeout event must carry the failed readiness condition: {timeout}"
1083
1358
  );
@@ -1103,7 +1378,15 @@ fn start_agent_with_transport_spawns_resume_not_stub() {
1103
1378
  seed_healthy_coordinator(&ws);
1104
1379
  let transport = OfflineTransport::new();
1105
1380
 
1106
- let _result = start_agent_with_transport(&ws, &AgentId::new("alpha"), false, false, false, None, &transport);
1381
+ let _result = start_agent_with_transport(
1382
+ &ws,
1383
+ &AgentId::new("alpha"),
1384
+ false,
1385
+ false,
1386
+ false,
1387
+ None,
1388
+ &transport,
1389
+ );
1107
1390
 
1108
1391
  let recorded = transport.spawn_records();
1109
1392
  assert_eq!(
@@ -1126,14 +1409,25 @@ fn start_agent_with_transport_spawns_resume_not_stub() {
1126
1409
  fn add_agent_with_transport_spawns_new_worker_not_stub() {
1127
1410
  let team = temp_ws().join("addteam");
1128
1411
  std::fs::create_dir_all(team.join("agents")).unwrap();
1129
- std::fs::write(team.join("TEAM.md"), "---\nname: addteam\nobjective: Add probe.\nprovider: codex\n---\n\nteam.\n").unwrap();
1412
+ std::fs::write(
1413
+ team.join("TEAM.md"),
1414
+ "---\nname: addteam\nobjective: Add probe.\nprovider: codex\n---\n\nteam.\n",
1415
+ )
1416
+ .unwrap();
1130
1417
  std::fs::write(team.join("agents").join("implementer.md"), QS_VALID_ROLE).unwrap(); // existing agent
1131
1418
  let role_file = team.join("worker2-role.md"); // OUTSIDE agents/ -> not a duplicate of an existing agent
1132
1419
  std::fs::write(&role_file, DELEG_ROLE_WORKER2).unwrap();
1133
1420
  seed_healthy_coordinator(&team);
1134
1421
  let transport = OfflineTransport::new();
1135
1422
 
1136
- let _result = add_agent_with_transport(&team, &AgentId::new("worker2"), &role_file, false, None, &transport);
1423
+ let _result = add_agent_with_transport(
1424
+ &team,
1425
+ &AgentId::new("worker2"),
1426
+ &role_file,
1427
+ false,
1428
+ None,
1429
+ &transport,
1430
+ );
1137
1431
 
1138
1432
  // (a) the recompiled spec was written (real subsystem step). E5: under .team/runtime/<team_key>/,
1139
1433
  // NOT the user team dir.
@@ -1179,7 +1473,9 @@ fn quick_start_seeds_tasks_key_from_compiled_spec() {
1179
1473
  let team = quick_start_team_dir(QS_VALID_ROLE);
1180
1474
  let transport = OfflineTransport::new();
1181
1475
  let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
1182
- let workspace = team.parent().expect("team_workspace(<base>/teamdir) = <base>");
1476
+ let workspace = team
1477
+ .parent()
1478
+ .expect("team_workspace(<base>/teamdir) = <base>");
1183
1479
  let state = crate::state::persist::load_runtime_state(workspace)
1184
1480
  .expect("runtime state.json must exist after quick_start");
1185
1481
  // (b) the tasks KEY must always be present and a JSON array (golden launch/core.py:69 default []).
@@ -1191,10 +1487,14 @@ fn quick_start_seeds_tasks_key_from_compiled_spec() {
1191
1487
  state.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>())
1192
1488
  ),
1193
1489
  };
1194
- let tasks = tasks.as_array().expect("`tasks` must be a JSON array (golden default [])");
1490
+ let tasks = tasks
1491
+ .as_array()
1492
+ .expect("`tasks` must be a JSON array (golden default [])");
1195
1493
  // (a) spec.tasks must be carried into runtime state — the doc compiler's default task id.
1196
1494
  assert!(
1197
- tasks.iter().any(|t| t.get("id").and_then(|v| v.as_str()) == Some("task_initial")),
1495
+ tasks
1496
+ .iter()
1497
+ .any(|t| t.get("id").and_then(|v| v.as_str()) == Some("task_initial")),
1198
1498
  "state.tasks must carry the compiled spec's task (id=task_initial); got {tasks:?}"
1199
1499
  );
1200
1500
  }
@@ -1228,11 +1528,18 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
1228
1528
  let team = quick_start_team_dir(QS_VALID_ROLE);
1229
1529
  let transport = OfflineTransport::new();
1230
1530
  let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
1231
- let workspace = team.parent().expect("team_workspace(<base>/teamdir) = <base>");
1531
+ let workspace = team
1532
+ .parent()
1533
+ .expect("team_workspace(<base>/teamdir) = <base>");
1232
1534
  // E5: spec_path in state now points to .team/runtime/<team_key>/, team_dir stays the user role dir.
1233
1535
  let spec_path = quick_start_runtime_spec_path(&team);
1234
1536
  let (raw, state) = raw_runtime_state(workspace);
1235
- let keys = state.as_object().expect("state root object").keys().cloned().collect::<Vec<_>>();
1537
+ let keys = state
1538
+ .as_object()
1539
+ .expect("state root object")
1540
+ .keys()
1541
+ .cloned()
1542
+ .collect::<Vec<_>>();
1236
1543
  assert_eq!(
1237
1544
  keys,
1238
1545
  vec![
@@ -1255,7 +1562,10 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
1255
1562
  truth (no shadow copy on the root that callers could read across teams); \
1256
1563
  raw={raw}"
1257
1564
  );
1258
- assert_eq!(state["spec_path"], json!(spec_path.canonicalize().unwrap().to_string_lossy()));
1565
+ assert_eq!(
1566
+ state["spec_path"],
1567
+ json!(spec_path.canonicalize().unwrap().to_string_lossy())
1568
+ );
1259
1569
  assert_eq!(state["workspace"], json!(workspace.to_string_lossy()));
1260
1570
  assert_eq!(state["team_dir"], json!(team.to_string_lossy()));
1261
1571
  assert_eq!(state["session_name"], json!("team-quickteam"));
@@ -1265,13 +1575,17 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
1265
1575
  "leader must be copied from compiled spec.leader"
1266
1576
  );
1267
1577
  assert!(
1268
- state["agents"].as_object().is_some_and(|agents| agents.contains_key("implementer")),
1578
+ state["agents"]
1579
+ .as_object()
1580
+ .is_some_and(|agents| agents.contains_key("implementer")),
1269
1581
  "existing agents value must remain seeded from compiled spec; got {:?}",
1270
1582
  state["agents"]
1271
1583
  );
1272
1584
  assert!(
1273
1585
  state["tasks"].as_array().is_some_and(|tasks| {
1274
- tasks.iter().any(|task| task.get("id").and_then(|id| id.as_str()) == Some("task_initial"))
1586
+ tasks
1587
+ .iter()
1588
+ .any(|task| task.get("id").and_then(|id| id.as_str()) == Some("task_initial"))
1275
1589
  }),
1276
1590
  "existing tasks value must remain seeded from compiled spec; got {:?}",
1277
1591
  state["tasks"]
@@ -1291,17 +1605,23 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
1291
1605
  #[serial(env)]
1292
1606
  fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
1293
1607
  const FIXED_SPAWNED_AT: &str = "2026-06-04T00:00:00+00:00";
1294
- let _clock_guard =
1295
- EnvVarGuard::set("TEAM_AGENT_TEST_FIXED_SPAWNED_AT", FIXED_SPAWNED_AT);
1608
+ let _clock_guard = EnvVarGuard::set("TEAM_AGENT_TEST_FIXED_SPAWNED_AT", FIXED_SPAWNED_AT);
1296
1609
  let team = quick_start_team_dir(QS_VALID_ROLE);
1297
1610
  let transport = OfflineTransport::new();
1298
1611
  let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
1299
- let workspace = team.parent().expect("team_workspace(<base>/teamdir) = <base>");
1612
+ let workspace = team
1613
+ .parent()
1614
+ .expect("team_workspace(<base>/teamdir) = <base>");
1300
1615
  let (raw, state) = raw_runtime_state(workspace);
1301
1616
  let agent = state
1302
1617
  .pointer("/agents/implementer")
1303
1618
  .unwrap_or_else(|| panic!("implementer agent state missing; raw={raw}"));
1304
- let keys = agent.as_object().expect("agent state object").keys().cloned().collect::<Vec<_>>();
1619
+ let keys = agent
1620
+ .as_object()
1621
+ .expect("agent state object")
1622
+ .keys()
1623
+ .cloned()
1624
+ .collect::<Vec<_>>();
1305
1625
  assert_eq!(
1306
1626
  keys,
1307
1627
  vec![
@@ -1335,7 +1655,9 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
1335
1655
  assert_eq!(agent["window"], json!("implementer"));
1336
1656
  assert_eq!(
1337
1657
  agent["mcp_config"],
1338
- json!(workspace.join(".team/runtime/mcp/implementer.json").to_string_lossy())
1658
+ json!(workspace
1659
+ .join(".team/runtime/mcp/implementer.json")
1660
+ .to_string_lossy())
1339
1661
  );
1340
1662
  assert_eq!(
1341
1663
  agent["permissions"],
@@ -1368,7 +1690,9 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
1368
1690
  fn quick_start_paused_agent_state_is_paused_provider_only_and_not_spawned() {
1369
1691
  let team = quick_start_team_dir(QS_VALID_ROLE);
1370
1692
  let spec_path = compiled_spec_path_with_paused_agent(&team);
1371
- let workspace = team.parent().expect("team_workspace(<base>/teamdir) = <base>");
1693
+ let workspace = team
1694
+ .parent()
1695
+ .expect("team_workspace(<base>/teamdir) = <base>");
1372
1696
  crate::state::persist::save_runtime_state(
1373
1697
  workspace,
1374
1698
  &json!({
@@ -1392,7 +1716,12 @@ fn quick_start_paused_agent_state_is_paused_provider_only_and_not_spawned() {
1392
1716
  let agent = state
1393
1717
  .pointer("/agents/implementer")
1394
1718
  .unwrap_or_else(|| panic!("implementer agent state missing; raw={raw}"));
1395
- let keys = agent.as_object().expect("agent state object").keys().cloned().collect::<Vec<_>>();
1719
+ let keys = agent
1720
+ .as_object()
1721
+ .expect("agent state object")
1722
+ .keys()
1723
+ .cloned()
1724
+ .collect::<Vec<_>>();
1396
1725
  assert_eq!(
1397
1726
  keys,
1398
1727
  vec!["status", "provider"],