@team-agent/installer 0.5.77 → 0.5.80

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 (95) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +161 -29
  4. package/crates/team-agent/src/cli/diagnose.rs +333 -68
  5. package/crates/team-agent/src/cli/emit.rs +24 -8
  6. package/crates/team-agent/src/cli/mod.rs +543 -98
  7. package/crates/team-agent/src/cli/models.rs +393 -32
  8. package/crates/team-agent/src/cli/named_address.rs +19 -5
  9. package/crates/team-agent/src/cli/profile.rs +35 -4
  10. package/crates/team-agent/src/cli/spec.rs +7 -2
  11. package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
  12. package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
  13. package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
  14. package/crates/team-agent/src/cli/status_port/store.rs +1 -1
  15. package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
  16. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
  17. package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
  18. package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
  19. package/crates/team-agent/src/cli/types.rs +1 -0
  20. package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
  21. package/crates/team-agent/src/leader/lease.rs +492 -33
  22. package/crates/team-agent/src/leader/owner_bind.rs +40 -12
  23. package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
  24. package/crates/team-agent/src/leader/rediscover.rs +36 -6
  25. package/crates/team-agent/src/leader/start.rs +197 -88
  26. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
  27. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
  28. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
  29. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
  30. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
  31. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +86 -23
  32. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
  33. package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
  34. package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
  35. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
  36. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
  37. package/crates/team-agent/src/lifecycle/launch.rs +30 -7
  38. package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
  39. package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
  40. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
  41. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +996 -66
  42. package/crates/team-agent/src/lifecycle/restart.rs +2 -2
  43. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
  44. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
  45. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
  46. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
  47. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -4
  48. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
  49. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
  50. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
  51. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
  52. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
  53. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
  54. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
  55. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
  56. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
  57. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
  58. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
  59. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
  60. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -2
  61. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
  62. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
  63. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
  64. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +54 -9
  65. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
  66. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
  67. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
  68. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
  69. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
  70. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
  71. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  72. package/crates/team-agent/src/mcp_server/wire.rs +140 -11
  73. package/crates/team-agent/src/messaging/delivery.rs +221 -80
  74. package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
  75. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
  76. package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
  77. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
  78. package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
  79. package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
  80. package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
  81. package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
  82. package/crates/team-agent/src/state/mod.rs +1 -0
  83. package/crates/team-agent/src/state/persist.rs +102 -4
  84. package/crates/team-agent/src/state/projection.rs +7 -1
  85. package/crates/team-agent/src/state/repository.rs +29 -0
  86. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +43 -21
  87. package/crates/team-agent/src/tmux_backend/tests.rs +149 -4
  88. package/crates/team-agent/src/tmux_backend.rs +239 -127
  89. package/crates/team-agent/src/transport.rs +78 -0
  90. package/crates/team-agent/src/transport_factory.rs +53 -0
  91. package/package.json +4 -5
  92. package/skills/team-agent/SKILL.md +45 -17
  93. package/skills/team-agent/command-coverage.json +4 -29
  94. package/npm/check-skill-links.mjs +0 -88
  95. package/skills/team-agent/references/team-agent-operator.md +0 -672
@@ -1,8 +1,18 @@
1
+ #[path = "../../../tests/support/hermetic.rs"]
2
+ mod hermetic_guard;
3
+
1
4
  use super::*;
2
5
  use crate::transport::test_support::OfflineTransport;
3
6
  use serde_json::json;
4
7
  use serial_test::serial;
5
8
 
9
+ fn enter_hermetic(tag: &str) -> hermetic_guard::HermeticTestEnv {
10
+ let env = hermetic_guard::HermeticTestEnv::enter(tag);
11
+ env.scrub_tmux();
12
+ env.assert_no_real_tmux();
13
+ env
14
+ }
15
+
6
16
  fn launch_source() -> String {
7
17
  let src = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/lifecycle");
8
18
  let mut files = vec![src.join("launch.rs")];
@@ -26,9 +36,6 @@ fn launch_source() -> String {
26
36
  .collect()
27
37
  }
28
38
 
29
- #[allow(dead_code)]
30
- struct HermeticTestEnv;
31
-
32
39
  const QS_TEAM_MD: &str =
33
40
  "---\nname: quickteam\nobjective: Quick start.\nprovider: codex\n---\n\nQuick-start team.\n";
34
41
  pub(super) const QS_VALID_ROLE: &str = "---\nname: implementer\nrole: Implementation Engineer\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ndangerously_skip_permissions: false\ntools:\n - mcp_team\n---\n\nImplement bounded tasks.\n";
@@ -163,7 +170,9 @@ fn unowned_running_ws() -> PathBuf {
163
170
  // hardcoded "no role docs found" PreflightBlocked. Golden: quick_start.py → _compile_team_dir_spec
164
171
  // writes spec_path=team_dir/team.spec.yaml BEFORE launch.
165
172
  #[test]
173
+ #[serial(env)]
166
174
  fn quick_start_compiles_real_spec_to_team_spec_yaml() {
175
+ let _hermetic = enter_hermetic("launch-spawn-compile-spec");
167
176
  let team = quick_start_team_dir(QS_VALID_ROLE);
168
177
  let transport = OfflineTransport::new();
169
178
  let result = quick_start_with_transport(&team, None, true, None, &transport);
@@ -196,7 +205,9 @@ fn quick_start_compiles_real_spec_to_team_spec_yaml() {
196
205
  }
197
206
 
198
207
  #[test]
208
+ #[serial(env)]
199
209
  fn quick_start_without_team_id_uses_compiled_team_name_as_canonical_key() {
210
+ let _hermetic = enter_hermetic("launch-spawn-canonical-team");
200
211
  let team = quick_start_team_dir(QS_VALID_ROLE);
201
212
  let workspace = crate::model::paths::team_workspace(&team).unwrap();
202
213
  let transport = OfflineTransport::new();
@@ -216,7 +227,120 @@ fn quick_start_without_team_id_uses_compiled_team_name_as_canonical_key() {
216
227
  }
217
228
 
218
229
  #[test]
230
+ #[serial(env)]
231
+ fn existing_runtime_producer_scopes_canonical_team() {
232
+ let _hermetic = enter_hermetic("launch-spawn-existing-producer");
233
+ let team = quick_start_team_dir(QS_VALID_ROLE);
234
+ let transport = OfflineTransport::new();
235
+ let first = quick_start_with_transport(&team, Some("from-name"), true, None, &transport)
236
+ .expect("first quick-start");
237
+ match &first {
238
+ QuickStartReport::Ready { team: canonical, .. } => {
239
+ assert_eq!(canonical, "from-name", "Ready producer must emit canonical team");
240
+ }
241
+ other => panic!("first quick-start must be Ready; got {other:?}"),
242
+ }
243
+ let second = quick_start_with_transport(&team, Some("from-name"), true, None, &transport)
244
+ .expect("second quick-start");
245
+ match second {
246
+ QuickStartReport::ExistingRuntime {
247
+ team: canonical,
248
+ agent_ids,
249
+ ..
250
+ } => {
251
+ assert_eq!(canonical.as_deref(), Some("from-name"));
252
+ assert!(
253
+ agent_ids.iter().any(|id| id == "implementer"),
254
+ "ExistingRuntime producer agent_ids={agent_ids:?}"
255
+ );
256
+ }
257
+ other => panic!("duplicate quick-start must be ExistingRuntime; got {other:?}"),
258
+ }
259
+ }
260
+
261
+ #[test]
262
+ #[serial(env)]
263
+ fn existing_runtime_requested_bob_does_not_emit_alice_members_or_attach() {
264
+ let team = quick_start_team_dir(QS_VALID_ROLE);
265
+ let workspace = crate::model::paths::team_workspace(&team).unwrap();
266
+ let state = json!({
267
+ "active_team_key": "alice",
268
+ "agents": { "alice": { "window": "alice" } },
269
+ "session_name": "team-alice",
270
+ "tmux_endpoint": "/tmp/shared.sock",
271
+ "teams": {
272
+ "alice": {
273
+ "agents": { "alice": { "window": "alice" } },
274
+ "session_name": "team-alice"
275
+ },
276
+ "bob": {
277
+ "agents": { "bob": { "window": "bob" } },
278
+ "session_name": "team-bob"
279
+ }
280
+ }
281
+ });
282
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
283
+ let report = quick_start_with_transport(&team, Some("bob"), true, None, &OfflineTransport::new())
284
+ .expect("requested bob existing runtime");
285
+ match report {
286
+ QuickStartReport::ExistingRuntime {
287
+ team: canonical,
288
+ agent_ids,
289
+ session_name,
290
+ attach_commands,
291
+ ..
292
+ } => {
293
+ assert_eq!(canonical.as_deref(), Some("bob"));
294
+ assert_eq!(agent_ids, vec!["bob".to_string()]);
295
+ assert_eq!(
296
+ session_name.as_ref().map(|session| session.as_str()),
297
+ Some("team-bob")
298
+ );
299
+ assert!(
300
+ attach_commands.iter().any(|command| command.contains("team-bob")),
301
+ "attach must follow bob session; got {attach_commands:?}"
302
+ );
303
+ assert!(
304
+ attach_commands
305
+ .iter()
306
+ .all(|command| !command.contains("alice") && !command.contains("team-alice")),
307
+ "attach must not steer to alice; got {attach_commands:?}"
308
+ );
309
+ }
310
+ other => panic!("expected ExistingRuntime for bob; got {other:?}"),
311
+ }
312
+ }
313
+
314
+ #[test]
315
+ #[serial(env)]
316
+ fn existing_runtime_empty_top_level_agents_still_emits_bob() {
317
+ let team = quick_start_team_dir(QS_VALID_ROLE);
318
+ let workspace = crate::model::paths::team_workspace(&team).unwrap();
319
+ let state = json!({
320
+ "active_team_key": "bob",
321
+ "agents": {},
322
+ "teams": {
323
+ "bob": {
324
+ "agents": { "bob": { "window": "bob" } },
325
+ "session_name": "team-bob"
326
+ }
327
+ }
328
+ });
329
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
330
+ let report = quick_start_with_transport(&team, Some("bob"), true, None, &OfflineTransport::new())
331
+ .expect("requested bob with empty top-level agents");
332
+ match report {
333
+ QuickStartReport::ExistingRuntime { agent_ids, .. } => {
334
+ assert_eq!(agent_ids, vec!["bob".to_string()]);
335
+ }
336
+ other => panic!("expected ExistingRuntime; got {other:?}"),
337
+ }
338
+ }
339
+
340
+ #[test]
341
+ #[serial(env)]
219
342
  fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_collect() {
343
+ let _hermetic = enter_hermetic("launch-spawn-dot-team-workspace");
220
344
  let workspace = temp_ws();
221
345
  let team = workspace.join(".team").join("current");
222
346
  std::fs::create_dir_all(team.join("agents")).unwrap();
@@ -299,7 +423,9 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
299
423
  }
300
424
 
301
425
  #[test]
426
+ #[serial(env)]
302
427
  fn quick_start_default_workspace_compiled_spec_uses_project_root() {
428
+ let _hermetic = enter_hermetic("launch-spawn-default-workspace");
303
429
  let workspace = temp_ws();
304
430
  std::fs::create_dir_all(workspace.join("agents")).unwrap();
305
431
  std::fs::write(workspace.join("TEAM.md"), QS_TEAM_MD).unwrap();
@@ -375,215 +501,6 @@ fn quick_start_invalid_role_doc_surfaces_real_compile_error() {
375
501
  );
376
502
  }
377
503
 
378
- #[test]
379
- fn quick_start_absent_or_empty_roles_preserves_compile_outcome_without_pi_preflight_mutation() {
380
- for (label, create_agents, expected) in [
381
- ("absent", false, "missing agents directory"),
382
- ("empty", true, "no role docs found"),
383
- ] {
384
- let team = temp_ws().join(format!("teamdir-{label}"));
385
- std::fs::create_dir_all(&team).unwrap();
386
- std::fs::write(team.join("TEAM.md"), QS_TEAM_MD).unwrap();
387
- if create_agents {
388
- std::fs::create_dir(team.join("agents")).unwrap();
389
- }
390
- let workspace = crate::model::paths::team_workspace(&team).unwrap();
391
- let team_md_before = std::fs::read(team.join("TEAM.md")).unwrap();
392
- let absent_paths = [
393
- team.join("team.spec.yaml"),
394
- crate::state::persist::runtime_state_path(&workspace),
395
- crate::model::paths::runtime_spec_path(&workspace, "quickteam"),
396
- workspace.join("team_state.md"),
397
- workspace.join(".team/logs/events.jsonl"),
398
- workspace.join(".team/runtime/team.db"),
399
- ];
400
- for path in &absent_paths {
401
- assert!(!path.exists(), "precondition: {} must be absent", path.display());
402
- }
403
-
404
- let transport = OfflineTransport::new();
405
- let mut calls = 0;
406
- let mut discover = |_requested: &str| {
407
- calls += 1;
408
- Ok(Vec::new())
409
- };
410
- let error = quick_start_with_transport_in_workspace_with_display_pi_preflight(
411
- &workspace,
412
- &team,
413
- None,
414
- true,
415
- None,
416
- &transport,
417
- true,
418
- &mut discover,
419
- )
420
- .expect_err("an empty role input must preserve the existing compile outcome");
421
- drop(discover);
422
- assert_eq!(calls, 0, "{label} role input must not invoke Pi discovery");
423
- match error {
424
- LifecycleError::Compile(message) => {
425
- assert!(message.contains(expected), "{label}: {message}");
426
- }
427
- other => panic!("{label}: expected existing compile outcome, got {other:?}"),
428
- }
429
- assert_eq!(team_md_before, std::fs::read(team.join("TEAM.md")).unwrap());
430
- for path in &absent_paths {
431
- assert!(!path.exists(), "{label} role input created {}", path.display());
432
- }
433
- assert!(transport.calls().is_empty(), "{label}: no transport mutation");
434
- assert!(transport.spawn_records().is_empty(), "{label}: no spawn");
435
- }
436
- }
437
-
438
- #[test]
439
- fn quick_start_injected_pi_preflight_is_typed_and_mutation_free() {
440
- let role_doc = QS_VALID_ROLE.replace(
441
- "provider: codex\nmodel: gpt-5.5",
442
- "provider: pi\nmodel: gpt-5.6-sol",
443
- );
444
- let team = quick_start_team_dir(&role_doc);
445
- let workspace = crate::model::paths::team_workspace(&team).unwrap();
446
- let role_path = team.join("agents/implementer.md");
447
- let team_md_path = team.join("TEAM.md");
448
- let local_spec_path = team.join("team.spec.yaml");
449
- let runtime_state_path = crate::state::persist::runtime_state_path(&workspace);
450
- let runtime_spec_path = crate::model::paths::runtime_spec_path(&workspace, "quickteam");
451
- let pi_paths = crate::lifecycle::launch::pi_mcp::pi_seat_paths(
452
- &workspace,
453
- "quickteam",
454
- "implementer",
455
- );
456
- let role_before = std::fs::read(&role_path).unwrap();
457
- let team_md_before = std::fs::read(&team_md_path).unwrap();
458
- for path in [
459
- &local_spec_path,
460
- &runtime_state_path,
461
- &runtime_spec_path,
462
- &pi_paths.wrapper,
463
- &pi_paths.sessions,
464
- ] {
465
- assert!(!path.exists(), "precondition: {} must be absent", path.display());
466
- }
467
-
468
- let transport = OfflineTransport::new();
469
- let mut calls = 0;
470
- let mut discover = |requested: &str| {
471
- calls += 1;
472
- assert_eq!(requested, "gpt-5.6-sol");
473
- Ok(vec!["openai-codex/gpt-5.6-sol".into()])
474
- };
475
- let error = quick_start_with_transport_in_workspace_with_display_pi_preflight(
476
- &workspace,
477
- &team,
478
- None,
479
- true,
480
- None,
481
- &transport,
482
- true,
483
- &mut discover,
484
- )
485
- .expect_err("unqualified Pi role must fail before quick-start mutation");
486
- drop(discover);
487
- assert_eq!(calls, 1);
488
- match error {
489
- LifecycleError::PiModelPreflight {
490
- requested,
491
- candidates,
492
- action,
493
- not_ready,
494
- } => {
495
- assert_eq!(requested, "gpt-5.6-sol");
496
- assert_eq!(candidates, vec!["openai-codex/gpt-5.6-sol"]);
497
- assert_eq!(
498
- action,
499
- "copy a candidate into the role, or run `team-agent models --provider pi --search gpt-5.6-sol`"
500
- );
501
- assert!(!not_ready);
502
- }
503
- other => panic!("expected typed Pi preflight error, got {other:?}"),
504
- }
505
- assert_eq!(role_before, std::fs::read(&role_path).unwrap());
506
- assert_eq!(team_md_before, std::fs::read(&team_md_path).unwrap());
507
- for path in [
508
- &local_spec_path,
509
- &runtime_state_path,
510
- &runtime_spec_path,
511
- &pi_paths.wrapper,
512
- &pi_paths.sessions,
513
- ] {
514
- assert!(!path.exists(), "quick-start preflight created {}", path.display());
515
- }
516
- assert!(transport.calls().is_empty(), "no transport calls before preflight rejection");
517
- assert!(transport.spawn_records().is_empty());
518
- }
519
-
520
- #[test]
521
- fn quick_start_schema_invalid_pi_role_is_mutation_free() {
522
- let role_doc = QS_VALID_ROLE
523
- .replace(
524
- "provider: codex\nmodel: gpt-5.5",
525
- "provider: pi\nmodel: openai-codex/gpt-5.6-sol",
526
- )
527
- .replace(" - mcp_team", " - fs_read");
528
- let team = quick_start_team_dir(&role_doc);
529
- let workspace = crate::model::paths::team_workspace(&team).unwrap();
530
- let role_path = team.join("agents/implementer.md");
531
- let team_md_path = team.join("TEAM.md");
532
- let role_before = std::fs::read(&role_path).unwrap();
533
- let team_md_before = std::fs::read(&team_md_path).unwrap();
534
- let pi_paths = crate::lifecycle::launch::pi_mcp::pi_seat_paths(
535
- &workspace,
536
- "quickteam",
537
- "implementer",
538
- );
539
- let absent_paths = [
540
- team.join("team.spec.yaml"),
541
- crate::state::persist::runtime_state_path(&workspace),
542
- crate::model::paths::runtime_spec_path(&workspace, "quickteam"),
543
- workspace.join("team_state.md"),
544
- workspace.join(".team/logs/events.jsonl"),
545
- workspace.join(".team/runtime/team.db"),
546
- pi_paths.wrapper,
547
- pi_paths.sessions,
548
- ];
549
- for path in &absent_paths {
550
- assert!(!path.exists(), "precondition: {} must be absent", path.display());
551
- }
552
-
553
- let transport = OfflineTransport::new();
554
- let mut calls = 0;
555
- let mut discover = |_requested: &str| {
556
- calls += 1;
557
- Ok(Vec::new())
558
- };
559
- let error = quick_start_with_transport_in_workspace_with_display_pi_preflight(
560
- &workspace,
561
- &team,
562
- None,
563
- true,
564
- None,
565
- &transport,
566
- true,
567
- &mut discover,
568
- )
569
- .expect_err("invalid Pi schema must fail before quick-start mutation");
570
- drop(discover);
571
- assert_eq!(calls, 0, "qualified models must not invoke discovery");
572
- match error {
573
- LifecycleError::Compile(message) => {
574
- assert!(message.contains("Pi roles require mcp_team"), "{message}");
575
- }
576
- other => panic!("expected Pi role schema error, got {other:?}"),
577
- }
578
- assert_eq!(role_before, std::fs::read(&role_path).unwrap());
579
- assert_eq!(team_md_before, std::fs::read(&team_md_path).unwrap());
580
- for path in &absent_paths {
581
- assert!(!path.exists(), "schema preflight created {}", path.display());
582
- }
583
- assert!(transport.calls().is_empty(), "no transport calls before schema rejection");
584
- assert!(transport.spawn_records().is_empty());
585
- }
586
-
587
504
  // P0 — launch (dry_run) over a real compiled spec must resolve the REAL route/permission plan
588
505
  // (no spawn) — NOT the hardcoded RequirementUnmet stub. Golden: launch/core.py dry_run resolves
589
506
  // routing + permissions without starting any process.
@@ -813,7 +730,9 @@ fn unowned_running_ws_all_paused() -> PathBuf {
813
730
  // #15 — quick_start seeds runtime state under team_workspace(team_dir) (the PARENT), not inside the
814
731
  // team dir, so restart/status can locate it. Golden quick_start.py:35 team_workspace(team_dir).
815
732
  #[test]
733
+ #[serial(env)]
816
734
  fn spine_quick_start_seeds_state_under_team_workspace_not_team_dir() {
735
+ let _hermetic = enter_hermetic("launch-spawn-spine-workspace");
817
736
  let team = quick_start_team_dir(QS_VALID_ROLE); // <base>/teamdir
818
737
  let transport = OfflineTransport::new();
819
738
  let _ = quick_start_with_transport(&team, None, true, None, &transport);
@@ -1362,7 +1281,9 @@ fn seed_canonical_team_identity(workspace: &std::path::Path, team_key: &str) {
1362
1281
  // RED at assertion (NOT a panic). OS-safe: recording transport (no real tmux) + seeded-healthy
1363
1282
  // coordinator (start_coordinator AlreadyRunning -> no daemon subprocess).
1364
1283
  #[test]
1284
+ #[serial(env)]
1365
1285
  fn quick_start_with_transport_spawns_workers_not_dry_run() {
1286
+ let _hermetic = enter_hermetic("launch-spawn-spawn-path");
1366
1287
  let team = quick_start_team_dir(QS_VALID_ROLE); // one agent: implementer / provider codex
1367
1288
  let workspace = team.parent().expect("team_workspace(team_dir) = parent"); // where start_coordinator runs
1368
1289
  seed_healthy_coordinator(workspace);
@@ -1449,7 +1370,9 @@ fn adaptive_layout_plan_8_workers_is_3_3_2() {
1449
1370
  }
1450
1371
 
1451
1372
  #[test]
1373
+ #[serial(env)]
1452
1374
  fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
1375
+ let _hermetic = enter_hermetic("launch-spawn-adaptive-layout");
1453
1376
  let roles = (1..=4)
1454
1377
  .map(|i| {
1455
1378
  let id = format!("w{i}");
@@ -1532,7 +1455,9 @@ fn quick_start_tmux_backend_prefers_absolute_tmux_env_endpoint() {
1532
1455
  }
1533
1456
 
1534
1457
  #[test]
1458
+ #[serial(env)]
1535
1459
  fn quick_start_persists_selected_tmux_endpoint_and_attach_commands() {
1460
+ let _hermetic = enter_hermetic("launch-spawn-endpoint-attach");
1536
1461
  let team = quick_start_team_dir(QS_VALID_ROLE);
1537
1462
  let workspace = team.parent().expect("team_workspace(team_dir) = parent");
1538
1463
  seed_healthy_coordinator(workspace);
@@ -1591,7 +1516,9 @@ fn annotate_runtime_tmux_endpoint_persists_workspace_socket_as_full_path() {
1591
1516
  }
1592
1517
 
1593
1518
  #[test]
1519
+ #[serial(env)]
1594
1520
  fn quick_start_preserves_managed_leader_topology_and_emits_leader_attach_command() {
1521
+ let _hermetic = enter_hermetic("launch-spawn-managed-leader");
1595
1522
  let team = quick_start_team_dir(QS_VALID_ROLE);
1596
1523
  let workspace = team.parent().expect("team_workspace(team_dir) = parent");
1597
1524
  seed_healthy_coordinator(workspace);
@@ -1683,7 +1610,9 @@ fn attach_window_names_for_state_agents_include_managed_leader_and_layout_window
1683
1610
  }
1684
1611
 
1685
1612
  #[test]
1613
+ #[serial(env)]
1686
1614
  fn quick_start_no_display_keeps_one_window_per_agent() {
1615
+ let _hermetic = enter_hermetic("launch-spawn-no-display");
1687
1616
  let roles = ["w1", "w2"]
1688
1617
  .into_iter()
1689
1618
  .map(|id| (format!("{id}.md"), role_doc(id)))
@@ -3095,7 +3024,9 @@ fn start_agent_adaptive_restarts_missing_pane_in_existing_layout_window() {
3095
3024
  // OS-safe: OfflineTransport (zero real spawn).
3096
3025
  // ═══════════════════════════════════════════════════════════════════════════
3097
3026
  #[test]
3027
+ #[serial(env)]
3098
3028
  fn quick_start_seeds_tasks_key_from_compiled_spec() {
3029
+ let _hermetic = enter_hermetic("launch-spawn-tasks-key");
3099
3030
  let team = quick_start_team_dir(QS_VALID_ROLE);
3100
3031
  let transport = OfflineTransport::new();
3101
3032
  let _ = quick_start_with_transport(&team, None, true, None, &transport);
@@ -3146,6 +3077,7 @@ fn quick_start_seeds_tasks_key_from_compiled_spec() {
3146
3077
  #[test]
3147
3078
  #[serial(env)]
3148
3079
  fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
3080
+ let _hermetic = enter_hermetic("launch-spawn-state-seeds");
3149
3081
  // Bug 2 owner team-scope: top-level owner triple is dropped when the seeded
3150
3082
  // pane is empty; an ambient TMUX_PANE (tests run inside the dev's tmux session)
3151
3083
  // would otherwise leak into the caller-identity seed, inflate the seeded owner
@@ -3245,6 +3177,7 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
3245
3177
  #[test]
3246
3178
  #[serial(env)]
3247
3179
  fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
3180
+ let _hermetic = enter_hermetic("launch-spawn-running-state");
3248
3181
  const FIXED_SPAWNED_AT: &str = "2026-06-04T00:00:00+00:00";
3249
3182
  let _clock_guard = EnvVarGuard::set("TEAM_AGENT_TEST_FIXED_SPAWNED_AT", FIXED_SPAWNED_AT);
3250
3183
  let team = quick_start_team_dir(QS_VALID_ROLE);
@@ -1,3 +1,6 @@
1
+ #[path = "../../../tests/support/hermetic.rs"]
2
+ mod hermetic_guard;
3
+
1
4
  use super::agent_ops::lanea_team_ws;
2
5
  use super::lane_ops::{fork_ws, LaneHomeGuard, LaneTransport};
3
6
  use super::launch_spawn::{
@@ -19,11 +22,17 @@ use serde_json::json;
19
22
  use std::collections::BTreeSet;
20
23
  use std::time::Duration;
21
24
 
22
- #[allow(dead_code)]
23
- struct HermeticTestEnv;
25
+ fn enter_hermetic(tag: &str) -> hermetic_guard::HermeticTestEnv {
26
+ let env = hermetic_guard::HermeticTestEnv::enter(tag);
27
+ env.scrub_tmux();
28
+ env.assert_no_real_tmux();
29
+ env
30
+ }
24
31
 
25
32
  #[test]
33
+ #[serial_test::serial(env)]
26
34
  fn concurrent_reset_discard_session_serializes() {
35
+ let _hermetic = enter_hermetic("phase-b-concurrent-reset");
27
36
  let ids = (1..=6).map(|n| format!("w{n}")).collect::<Vec<_>>();
28
37
  let role_docs = ids
29
38
  .iter()
@@ -131,6 +140,7 @@ fn concurrent_reset_discard_session_serializes() {
131
140
  #[test]
132
141
  #[serial_test::serial(env)]
133
142
  fn add_fork_remove_share_lifecycle_lock_behavior() {
143
+ let _hermetic = enter_hermetic("phase-b-fork");
134
144
  let _home = LaneHomeGuard::enter("phase-b-fork");
135
145
  let (add_team, add_workspace, add_role, add_transport) = add_fixture();
136
146
  let add_agent = aid("w2");
@@ -313,7 +323,9 @@ fn add_fork_remove_share_lifecycle_lock_behavior() {
313
323
  }
314
324
 
315
325
  #[test]
326
+ #[serial_test::serial(env)]
316
327
  fn breal_spawn_ownership_mismatch_fails_without_state_pollution() {
328
+ let _hermetic = enter_hermetic("phase-b-spawn-ownership");
317
329
  let (workspace, _team) = breal_workspace();
318
330
  let transport = BRealTransport::misowned();
319
331
 
@@ -425,7 +437,9 @@ fn breal_reset_rehydrates_role_context_from_compiled_spec() {
425
437
  }
426
438
 
427
439
  #[test]
440
+ #[serial_test::serial(env)]
428
441
  fn breal_restart_rehydrates_role_context_from_compiled_spec() {
442
+ let _hermetic = enter_hermetic("phase-b-restart-role-context");
429
443
  let (workspace, _team) = breal_one_worker_workspace();
430
444
  strip_runtime_command_context(&workspace);
431
445
  let transport = BRealTransport::owned();
@@ -90,6 +90,9 @@ fn hermetic_caller_envs() -> &'static [&'static str] {
90
90
  "TEAM_AGENT_OWNER_TEAM_ID",
91
91
  "TEAM_AGENT_ACTIVE_TEAM",
92
92
  "TEAM_AGENT_ID",
93
+ "TEAM_AGENT_CALLER_PROVIDER",
94
+ "TEAM_AGENT_CALLER_PANE_ID",
95
+ "TEAM_AGENT_CALLER_TMUX_ENDPOINT",
93
96
  ]
94
97
  }
95
98
 
@@ -176,28 +179,40 @@ fn phase_f_golden_events_state_status_zero_drift() {
176
179
  #[test]
177
180
  #[serial_test::serial(env)]
178
181
  fn phase_golden_unbound_not_ready_requires_claim_leader() {
179
- // Unbound recovery guidance belongs to the diagnose/quick-start refusal
180
- // surface, not to the seven-field status brief.
181
182
  let diagnose = run_diagnose_after_quick_start();
182
183
  let issues = diagnose
183
184
  .get("issues")
184
185
  .and_then(Value::as_array)
185
186
  .cloned()
186
187
  .unwrap_or_default();
188
+ let issue_ids: Vec<&str> = issues
189
+ .iter()
190
+ .filter_map(|issue| issue.as_str())
191
+ .collect();
192
+ assert_eq!(
193
+ issue_ids
194
+ .iter()
195
+ .copied()
196
+ .filter(|id| *id == "leader_registry_index_missing")
197
+ .count(),
198
+ 1,
199
+ "tombstone after seeded owner without registry must be exactly index-missing; diagnose={diagnose}"
200
+ );
187
201
  assert!(
188
- issues.iter().any(|issue| {
189
- issue.as_str() == Some("leader_not_attached")
190
- || issue.get("id").and_then(Value::as_str) == Some("leader_channel_unbound")
191
- }),
192
- "tombstone fixture must still diagnose leader_receiver_unbound; diagnose={diagnose}"
202
+ !issue_ids.contains(&"leader_receiver_not_committed"),
203
+ "parallel not_committed bool must not appear; diagnose={diagnose}"
193
204
  );
194
205
  let repairs = diagnose
195
206
  .get("suggested_repairs")
196
207
  .map(Value::to_string)
197
208
  .unwrap_or_default();
198
209
  assert!(
199
- repairs.contains("claim-leader"),
200
- "diagnose must retain claim-leader recovery guidance; diagnose={diagnose}"
210
+ repairs.contains("publish the leader registry index"),
211
+ "index-missing repair must publish the index; diagnose={diagnose}"
212
+ );
213
+ assert!(
214
+ !repairs.contains("team-agent claim-leader --confirm"),
215
+ "index-missing must not recommend claim-leader --confirm; diagnose={diagnose}"
201
216
  );
202
217
  }
203
218
 
@@ -490,6 +505,36 @@ fn run_diagnose_after_quick_start() -> Value {
490
505
  false,
491
506
  )
492
507
  .expect("tombstone fixture must quick-start");
508
+ let mut state = crate::state::persist::load_runtime_state(&workspace)
509
+ .expect("tombstone fixture must persist runtime state");
510
+ crate::lifecycle::launch::seed_launched_owner_from_caller_with_provider_lookup(
511
+ &mut state,
512
+ crate::state::owner_gate::CallerIdentity {
513
+ pane_id: "%1".to_string(),
514
+ provider: "codex".to_string(),
515
+ machine_fingerprint: "fp".to_string(),
516
+ leader_session_uuid: "uuid-tombstone".to_string(),
517
+ leader_session_uuid_source: "env".to_string(),
518
+ },
519
+ |_| None,
520
+ );
521
+ crate::state::persist::save_runtime_state(&workspace, &state)
522
+ .expect("persist seeded owner without registry");
523
+ if let Some(dir) = crate::leader::registry::registry_dir() {
524
+ if let Ok(entries) = std::fs::read_dir(&dir) {
525
+ for entry in entries.flatten() {
526
+ let _ = std::fs::remove_file(entry.path());
527
+ }
528
+ }
529
+ }
530
+ assert!(
531
+ state
532
+ .pointer("/teams/teamdir/team_owner/pane_id")
533
+ .or_else(|| state.pointer("/team_owner/pane_id"))
534
+ .and_then(Value::as_str)
535
+ .is_some(),
536
+ "index-missing diagnose requires a persisted seeded owner"
537
+ );
493
538
  let diagnose = cmd_diagnose(&DiagnoseArgs {
494
539
  workspace,
495
540
  json: true,