@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
@@ -75,6 +75,12 @@ trait FreshQuickStartLeaderBindingOps {
75
75
  fn readback_failure_reason(&self) -> Option<&'static str> {
76
76
  None
77
77
  }
78
+ fn frozen_seed_receipt(&self) -> Option<(serde_json::Value, serde_json::Value)> {
79
+ None
80
+ }
81
+ fn this_attempt_extra_state_keys(&self) -> Vec<&'static str> {
82
+ Vec::new()
83
+ }
78
84
  }
79
85
 
80
86
  struct RuntimeFreshQuickStartLeaderBindingOps<'a> {
@@ -83,6 +89,8 @@ struct RuntimeFreshQuickStartLeaderBindingOps<'a> {
83
89
  cas_conflict: bool,
84
90
  applied_grant: Option<(serde_json::Value, serde_json::Value)>,
85
91
  registry_receipt: Option<crate::leader::registry::RegistryWriteReceipt>,
92
+ frozen_owner: Option<serde_json::Value>,
93
+ frozen_receiver: Option<serde_json::Value>,
86
94
  nonce_writer:
87
95
  Option<&'a dyn Fn(&str, &PaneId, &str) -> Result<String, crate::leader::LeaderError>>,
88
96
  }
@@ -149,17 +157,22 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
149
157
  self.last_failure_reason = Some("caller_pane_not_live");
150
158
  return false;
151
159
  };
152
- if !caller_target.active
153
- || caller_target
154
- .current_path
155
- .as_ref()
156
- .is_none_or(|path| path.as_os_str().is_empty())
160
+ if caller_target
161
+ .current_path
162
+ .as_ref()
163
+ .is_none_or(|path| path.as_os_str().is_empty())
157
164
  {
158
165
  self.last_failure_reason = Some("caller_cwd_unobservable");
159
166
  return false;
160
167
  }
161
- let expected_owner = state.get("team_owner").cloned();
162
- let expected_receiver = state.get("leader_receiver").cloned();
168
+ let expected_owner = self
169
+ .frozen_owner
170
+ .clone()
171
+ .or_else(|| state.get("team_owner").cloned());
172
+ let expected_receiver = self
173
+ .frozen_receiver
174
+ .clone()
175
+ .or_else(|| state.get("leader_receiver").cloned());
163
176
  match crate::leader::attach_leader_to_state_with_target_and_controls(
164
177
  workspace,
165
178
  state,
@@ -255,8 +268,14 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
255
268
  }
256
269
 
257
270
  fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
258
- if !launched_team_receiver_is_attached(workspace, team_key) {
259
- self.last_failure_reason = Some("registry_readback_unavailable");
271
+ let class = crate::lifecycle::launch::classify_leader_binding(workspace, team_key);
272
+ if class != super::LeaderBindingClass::Attached {
273
+ self.last_failure_reason = Some(match class {
274
+ super::LeaderBindingClass::IndexMissing => "registry_readback_unavailable",
275
+ super::LeaderBindingClass::Unknown => "receiver_live_channel_unavailable",
276
+ super::LeaderBindingClass::Unbound => "receiver_missing",
277
+ super::LeaderBindingClass::Attached => "registry_identity_mismatch",
278
+ });
260
279
  return false;
261
280
  }
262
281
  let Ok(resolved) = crate::state::projection::resolve_runtime_team_scope(
@@ -266,7 +285,9 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
266
285
  self.last_failure_reason = Some("receiver_scope_unavailable");
267
286
  return false;
268
287
  };
269
- let Some(receiver) = resolved.state.get("leader_receiver") else {
288
+ let Some(receiver) =
289
+ super::selected_team_leader_receiver(&resolved.state, team_key)
290
+ else {
270
291
  self.last_failure_reason = Some("receiver_missing");
271
292
  return false;
272
293
  };
@@ -288,6 +309,12 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
288
309
  fn readback_failure_reason(&self) -> Option<&'static str> {
289
310
  self.last_failure_reason
290
311
  }
312
+
313
+ fn frozen_seed_receipt(&self) -> Option<(serde_json::Value, serde_json::Value)> {
314
+ self.frozen_owner
315
+ .clone()
316
+ .zip(self.frozen_receiver.clone())
317
+ }
291
318
  }
292
319
 
293
320
  const FRESH_BIND_REFUSAL_EVENT: &str = "quick_start.leader_bind_refused";
@@ -300,6 +327,9 @@ fn record_fresh_bind_refusal(
300
327
  stage: &'static str,
301
328
  reason: &'static str,
302
329
  ) {
330
+ LAST_FRESH_BIND_REFUSAL.with(|cell| {
331
+ *cell.borrow_mut() = Some((stage, reason));
332
+ });
303
333
  let _ = crate::event_log::EventLog::new(workspace).write(
304
334
  FRESH_BIND_REFUSAL_EVENT,
305
335
  serde_json::json!({
@@ -311,12 +341,24 @@ fn record_fresh_bind_refusal(
311
341
  );
312
342
  }
313
343
 
344
+ thread_local! {
345
+ static LAST_FRESH_BIND_REFUSAL: std::cell::RefCell<Option<(&'static str, &'static str)>> =
346
+ const { std::cell::RefCell::new(None) };
347
+ }
348
+
349
+ fn take_fresh_bind_refusal() -> Option<(&'static str, &'static str)> {
350
+ LAST_FRESH_BIND_REFUSAL.with(|cell| cell.borrow_mut().take())
351
+ }
352
+
314
353
  fn refuse_fresh_bind(
315
354
  workspace: &Path,
316
355
  team_key: &str,
317
356
  stage: &'static str,
318
357
  reason: &'static str,
319
358
  ) -> Result<bool, LifecycleError> {
359
+ LAST_FRESH_BIND_REFUSAL.with(|cell| {
360
+ *cell.borrow_mut() = Some((stage, reason));
361
+ });
320
362
  record_fresh_bind_refusal(workspace, team_key, stage, reason);
321
363
  Ok(false)
322
364
  }
@@ -332,25 +374,6 @@ impl BindingFileSnapshot {
332
374
  Self { path, bytes }
333
375
  }
334
376
 
335
- fn restore(&self) {
336
- match &self.bytes {
337
- Some(bytes) => {
338
- let tmp = self.path.with_extension(format!(
339
- "rollback-{}",
340
- std::process::id()
341
- ));
342
- if std::fs::write(&tmp, bytes).is_ok()
343
- && std::fs::rename(&tmp, &self.path).is_err()
344
- {
345
- let _ = std::fs::remove_file(tmp);
346
- }
347
- }
348
- None => {
349
- let _ = std::fs::remove_file(&self.path);
350
- }
351
- }
352
- }
353
-
354
377
  }
355
378
 
356
379
  fn binding_snapshots(workspace: &Path, team_key: &str) -> Vec<BindingFileSnapshot> {
@@ -475,7 +498,7 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
475
498
  workspace,
476
499
  team_key,
477
500
  "strict_provider",
478
- "provider_unresolved",
501
+ "invalid_caller_tuple",
479
502
  );
480
503
  }
481
504
  crate::layout::worker_env::CallerProviderResolution::Absent => {
@@ -503,20 +526,44 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
503
526
  // the owner. Cleanup is allowed only when the caller supplies the exact
504
527
  // in-memory seed created immediately before this bind attempt and the
505
528
  // projected state still contains that seed.
506
- let fresh_seeded_binding = seeded_owner.is_some_and(|seed| {
529
+ let current_receiver = state.get("leader_receiver");
530
+ let frozen_receipt = ops.frozen_seed_receipt();
531
+ let fresh_seeded_binding = if let Some((frozen_owner, frozen_receiver)) = frozen_receipt.as_ref()
532
+ {
507
533
  state
508
534
  .get("team_owner")
509
- .is_some_and(|current| current == seed)
510
- });
511
- let seeded_receiver = if fresh_seeded_binding {
512
- state.get("leader_receiver").cloned()
535
+ .is_some_and(|current| current == frozen_owner)
536
+ && current_receiver.is_some_and(|current| current == frozen_receiver)
513
537
  } else {
514
- None
538
+ let pending_seed_receiver = current_receiver.is_some_and(|receiver| {
539
+ receiver.get("status").and_then(serde_json::Value::as_str) == Some("pending")
540
+ && receiver
541
+ .get("discovery")
542
+ .and_then(serde_json::Value::as_str)
543
+ == Some("quick_start_seed")
544
+ });
545
+ seeded_owner.is_some_and(|seed| {
546
+ state
547
+ .get("team_owner")
548
+ .is_some_and(|current| current == seed)
549
+ && pending_seed_receiver
550
+ })
515
551
  };
552
+ let seeded_receiver = frozen_receipt
553
+ .as_ref()
554
+ .map(|(_, receiver)| receiver.clone())
555
+ .or_else(|| {
556
+ if fresh_seeded_binding {
557
+ current_receiver.cloned()
558
+ } else {
559
+ None
560
+ }
561
+ });
516
562
  // Same-Team owner collision remains fail-closed. Cross-Team registry rows
517
563
  // are intentionally irrelevant: pane ids are scoped to their tmux server,
518
564
  // and independent endpoints may reuse the same numeric id.
519
565
  if has_persisted_binding
566
+ && !fresh_seeded_binding
520
567
  && !persisted_binding_matches_verified_pane(
521
568
  &state,
522
569
  workspace,
@@ -591,35 +638,53 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
591
638
  let registry_rollback_error = ops
592
639
  .registry_receipt()
593
640
  .and_then(|receipt| restore_registry_receipt(&snapshots, &receipt).err());
594
- if let Some((expected_owner, expected_receiver)) = cleanup_grant.as_ref() {
641
+ let state_rollback_error = if let Some((expected_owner, expected_receiver)) =
642
+ cleanup_grant.as_ref()
643
+ {
595
644
  clear_fresh_binding_on_refusal(
596
645
  workspace,
597
646
  &mut state,
598
647
  team_key,
599
648
  expected_owner,
600
649
  Some((expected_owner, expected_receiver)),
601
- )?;
650
+ )
651
+ .err()
602
652
  } else if let Some(seed) = seeded_owner.filter(|seed| {
603
653
  state
604
654
  .get("team_owner")
605
655
  .is_some_and(|current| current == *seed)
606
656
  }) {
607
- clear_fresh_binding_on_refusal(workspace, &mut state, team_key, seed, None)?;
608
- }
609
- if let Some(error) = registry_rollback_error {
657
+ clear_fresh_binding_on_refusal(workspace, &mut state, team_key, seed, None).err()
658
+ } else {
659
+ restore_this_attempt_state(
660
+ workspace,
661
+ team_key,
662
+ snapshots.first(),
663
+ ops.applied_grant().as_ref(),
664
+ &ops.this_attempt_extra_state_keys(),
665
+ )
666
+ .err()
667
+ };
668
+ if let Some(error) =
669
+ combine_rollback_errors(state_rollback_error, registry_rollback_error)
670
+ {
610
671
  return Err(error);
611
672
  }
612
673
  } else {
613
- // No caller-seeded owner was ours to clean up. Restore only the
614
- // runtime snapshot; a registry write is reverted only with its
615
- // exact receipt under the registry owner's lock.
616
674
  let registry_rollback_error = ops
617
675
  .registry_receipt()
618
676
  .and_then(|receipt| restore_registry_receipt(&snapshots, &receipt).err());
619
- if let Some(snapshot) = snapshots.first() {
620
- snapshot.restore();
621
- }
622
- if let Some(error) = registry_rollback_error {
677
+ let state_rollback_error = restore_this_attempt_state(
678
+ workspace,
679
+ team_key,
680
+ snapshots.first(),
681
+ ops.applied_grant().as_ref(),
682
+ &ops.this_attempt_extra_state_keys(),
683
+ )
684
+ .err();
685
+ if let Some(error) =
686
+ combine_rollback_errors(state_rollback_error, registry_rollback_error)
687
+ {
623
688
  return Err(error);
624
689
  }
625
690
  }
@@ -627,6 +692,112 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
627
692
  Ok(committed)
628
693
  }
629
694
 
695
+ fn combine_rollback_errors(
696
+ state: Option<LifecycleError>,
697
+ registry: Option<LifecycleError>,
698
+ ) -> Option<LifecycleError> {
699
+ match (state, registry) {
700
+ (None, None) => None,
701
+ (Some(error), None) | (None, Some(error)) => Some(error),
702
+ (Some(state), Some(registry)) => Some(LifecycleError::StatePersist(format!(
703
+ "{state}; {registry}"
704
+ ))),
705
+ }
706
+ }
707
+
708
+ fn restore_this_attempt_state(
709
+ workspace: &Path,
710
+ team_key: &str,
711
+ snapshot: Option<&BindingFileSnapshot>,
712
+ this_grant: Option<&(serde_json::Value, serde_json::Value)>,
713
+ extra_keys: &[&str],
714
+ ) -> Result<(), LifecycleError> {
715
+ if this_grant.is_none() && extra_keys.is_empty() {
716
+ return Ok(());
717
+ }
718
+ let mut current = match crate::state::persist::load_runtime_state(workspace) {
719
+ Ok(state) => state,
720
+ Err(error) => {
721
+ return Err(LifecycleError::StatePersist(format!(
722
+ "quick-start binding cleanup failed: {error}"
723
+ )));
724
+ }
725
+ };
726
+ strip_this_attempt_extra_keys(&mut current, extra_keys);
727
+ let Some((expected_owner, expected_receiver)) = this_grant else {
728
+ return crate::state::repository::StateRepository::new(workspace)
729
+ .save(
730
+ crate::state::repository::StateWriteIntent::QuickStartBindingCleanup { team_key },
731
+ &current,
732
+ )
733
+ .map_err(|error| {
734
+ LifecycleError::StatePersist(format!("quick-start binding cleanup failed: {error}"))
735
+ });
736
+ };
737
+ let previous = match snapshot.map(|snapshot| snapshot.bytes.as_deref()) {
738
+ Some(Some(bytes)) => serde_json::from_slice::<serde_json::Value>(bytes).map_err(|error| {
739
+ LifecycleError::StatePersist(format!("quick-start binding cleanup failed: {error}"))
740
+ })?,
741
+ Some(None) | None => serde_json::Value::Null,
742
+ };
743
+ let previous_owner = previous
744
+ .pointer(&format!("/teams/{team_key}/team_owner"))
745
+ .cloned()
746
+ .or_else(|| previous.get("team_owner").cloned())
747
+ .unwrap_or(serde_json::Value::Null);
748
+ let previous_receiver = previous
749
+ .pointer(&format!("/teams/{team_key}/leader_receiver"))
750
+ .cloned()
751
+ .or_else(|| previous.get("leader_receiver").cloned())
752
+ .unwrap_or(serde_json::Value::Null);
753
+ let no_previous_binding = previous_owner.is_null() && previous_receiver.is_null();
754
+ if no_previous_binding {
755
+ if let Some(team) = current
756
+ .get_mut("teams")
757
+ .and_then(serde_json::Value::as_object_mut)
758
+ .and_then(|teams| teams.get_mut(team_key))
759
+ .and_then(serde_json::Value::as_object_mut)
760
+ {
761
+ team.insert("team_owner".to_string(), serde_json::Value::Null);
762
+ team.insert("leader_receiver".to_string(), serde_json::Value::Null);
763
+ }
764
+ return crate::state::repository::StateRepository::new(workspace)
765
+ .save(
766
+ crate::state::repository::StateWriteIntent::ClearExactTeamOwnerAndReceiver {
767
+ team_key,
768
+ seed: expected_owner,
769
+ expected_receiver,
770
+ },
771
+ &current,
772
+ )
773
+ .map_err(|error| {
774
+ LifecycleError::StatePersist(format!("quick-start binding cleanup failed: {error}"))
775
+ });
776
+ }
777
+ crate::state::repository::StateRepository::new(workspace)
778
+ .save(
779
+ crate::state::repository::StateWriteIntent::RestoreExactTeamOwnerAndReceiver {
780
+ team_key,
781
+ expected_owner,
782
+ expected_receiver,
783
+ previous_owner: &previous_owner,
784
+ previous_receiver: &previous_receiver,
785
+ },
786
+ &current,
787
+ )
788
+ .map_err(|error| {
789
+ LifecycleError::StatePersist(format!("quick-start binding cleanup failed: {error}"))
790
+ })
791
+ }
792
+
793
+ fn strip_this_attempt_extra_keys(state: &mut serde_json::Value, extra_keys: &[&str]) {
794
+ if let Some(root) = state.as_object_mut() {
795
+ for key in extra_keys {
796
+ root.remove(*key);
797
+ }
798
+ }
799
+ }
800
+
630
801
  fn restore_registry_receipt(
631
802
  snapshots: &[BindingFileSnapshot],
632
803
  receipt: &crate::leader::registry::RegistryWriteReceipt,
@@ -691,10 +862,91 @@ fn clear_fresh_binding_on_refusal(
691
862
  )))
692
863
  }
693
864
 
865
+ fn existing_runtime_identity_view(
866
+ state: &serde_json::Value,
867
+ requested_team: Option<&str>,
868
+ ) -> serde_json::Value {
869
+ let Some(team) = requested_team.filter(|team| !team.is_empty()) else {
870
+ return state.clone();
871
+ };
872
+ let teams = state.get("teams").and_then(serde_json::Value::as_object);
873
+ if teams.is_some_and(|teams| teams.contains_key(team) || !teams.is_empty()) {
874
+ return crate::state::projection::project_top_level_view(state, team);
875
+ }
876
+ state.clone()
877
+ }
878
+
879
+ fn preflight_fresh_leader_identity(
880
+ transport: &dyn Transport,
881
+ ) -> Option<(String, Vec<String>, Vec<String>)> {
882
+ let pane = std::env::var("TMUX_PANE")
883
+ .ok()
884
+ .filter(|pane| !pane.is_empty());
885
+ let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
886
+ match crate::layout::worker_env::caller_provider_resolution(
887
+ pane.as_deref(),
888
+ current_endpoint.as_deref(),
889
+ transport.tmux_endpoint().as_deref(),
890
+ ) {
891
+ crate::layout::worker_env::CallerProviderResolution::Invalid => Some((
892
+ "quick-start refused before spawn: invalid caller tuple".to_string(),
893
+ vec!["stage=strict_provider reason=invalid_caller_tuple".to_string()],
894
+ vec![
895
+ "fix conflicting CALLER_* / TEAM_AGENT_LEADER_* identity; do not run claim-leader"
896
+ .to_string(),
897
+ ],
898
+ )),
899
+ crate::layout::worker_env::CallerProviderResolution::Valid(_) => None,
900
+ crate::layout::worker_env::CallerProviderResolution::Absent => {
901
+ let Some(pane) = pane else {
902
+ return None;
903
+ };
904
+ let command = transport
905
+ .query(
906
+ &Target::Pane(PaneId::new(pane)),
907
+ PaneField::PaneCurrentCommand,
908
+ )
909
+ .ok()
910
+ .flatten()
911
+ .unwrap_or_default();
912
+ let explicit = std::env::var("TEAM_AGENT_LEADER_PROVIDER")
913
+ .ok()
914
+ .filter(|provider| !provider.is_empty());
915
+ if command.trim().is_empty() {
916
+ return Some((
917
+ "quick-start refused before spawn: command unobservable".to_string(),
918
+ vec!["stage=command_observation reason=command_unobservable".to_string()],
919
+ vec![
920
+ "run from a live provider pane; do not run claim-leader".to_string(),
921
+ ],
922
+ ));
923
+ }
924
+ if crate::leader::owner_bind::strict_owner_bind_provider(
925
+ explicit.as_deref(),
926
+ &command,
927
+ )
928
+ .is_some()
929
+ {
930
+ None
931
+ } else {
932
+ Some((
933
+ "quick-start refused before spawn: provider unresolved".to_string(),
934
+ vec!["stage=strict_provider reason=provider_unresolved".to_string()],
935
+ vec![
936
+ "set TEAM_AGENT_LEADER_PROVIDER or run from a provider pane; do not run claim-leader"
937
+ .to_string(),
938
+ ],
939
+ ))
940
+ }
941
+ }
942
+ }
943
+ }
944
+
694
945
  fn bind_fresh_quick_start_leader(
695
946
  workspace: &Path,
696
947
  team_key: &str,
697
948
  seeded_owner: Option<&serde_json::Value>,
949
+ seeded_receiver: Option<&serde_json::Value>,
698
950
  transport: &dyn Transport,
699
951
  ) -> Result<bool, LifecycleError> {
700
952
  bind_fresh_quick_start_leader_with(
@@ -707,6 +959,8 @@ fn bind_fresh_quick_start_leader(
707
959
  cas_conflict: false,
708
960
  applied_grant: None,
709
961
  registry_receipt: None,
962
+ frozen_owner: seeded_owner.cloned(),
963
+ frozen_receiver: seeded_receiver.cloned(),
710
964
  nonce_writer: None,
711
965
  },
712
966
  )
@@ -982,32 +1236,24 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
982
1236
  open_display: bool,
983
1237
  discover: &mut dyn FnMut(&str) -> Result<Vec<String>, ()>,
984
1238
  ) -> Result<QuickStartReport, LifecycleError> {
1239
+ // B-7 / 036b N38 三行 fail-fast — TEAM_AGENT_LEADER_PANE_ID 主动路径在 quick-start
1240
+ // 入口验活;死/缺(Dead)的 pane 必须明确报错,不可 silent bind 到 spawner /
1241
+ // owner_bind / lease / display 任一消费点。被动路径(display/seed 等)各自走
1242
+ // 降级+event,不在这里挡。错误三行式:error(含 pane id 字面)/action(unset
1243
+ // 或修 env)/log(env var 名)。
1244
+ let team_workspace = team_workspace(agents_dir);
1245
+ let warning_workspaces = [workspace, team_workspace.as_path()];
1246
+ validate_active_leader_pane_env_with_workspaces(transport, &warning_workspaces)?;
985
1247
  if !agents_dir.exists() {
986
1248
  return Err(LifecycleError::Compile(format!(
987
1249
  "agents dir not found: {}",
988
1250
  agents_dir.display()
989
1251
  )));
990
1252
  }
991
- crate::compiler::preflight_pi_models_in_team_with(agents_dir, discover).map_err(|error| {
992
- LifecycleError::PiModelPreflight {
993
- requested: error.requested,
994
- candidates: error.candidates,
995
- action: error.action,
996
- not_ready: error.not_ready,
997
- }
998
- })?;
1253
+ crate::lifecycle::launch::run_pi_catalog_preflight(agents_dir, discover)?;
999
1254
  let workspace = workspace.to_path_buf();
1000
1255
  let mut spec = crate::compiler::compile_team(agents_dir)
1001
1256
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
1002
- // B-7 / 036b N38 三行 fail-fast — TEAM_AGENT_LEADER_PANE_ID 主动路径在 quick-start
1003
- // 入口验活;死/缺(Dead)的 pane 必须明确报错,不可 silent bind 到 spawner /
1004
- // owner_bind / lease / display 任一消费点。被动路径(display/seed 等)各自走
1005
- // 降级+event,不在这里挡。错误三行式:error(含 pane id 字面)/action(unset
1006
- // 或修 env)/log(env var 名)。 Role/schema and Pi model admission above is pure and
1007
- // must finish before this validation can emit a warning event.
1008
- let team_workspace = team_workspace(agents_dir);
1009
- let warning_workspaces = [workspace.as_path(), team_workspace.as_path()];
1010
- validate_active_leader_pane_env_with_workspaces(transport, &warning_workspaces)?;
1011
1257
  override_spec_workspace(&mut spec, &workspace);
1012
1258
  if !open_display {
1013
1259
  override_spec_display_backend(&mut spec, "none");
@@ -1045,7 +1291,8 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
1045
1291
  .as_deref()
1046
1292
  .is_none_or(|team| runtime_state_has_quick_start_team(&state, team))
1047
1293
  {
1048
- let session_name = state
1294
+ let identity = existing_runtime_identity_view(&state, requested_team.as_deref());
1295
+ let session_name = identity
1049
1296
  .get("session_name")
1050
1297
  .and_then(serde_json::Value::as_str)
1051
1298
  .filter(|s| !s.is_empty())
@@ -1053,11 +1300,19 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
1053
1300
  let attach_commands = session_name
1054
1301
  .as_ref()
1055
1302
  .map(|session| {
1056
- let windows = quick_start_attach_window_names(&state);
1303
+ let windows = quick_start_attach_window_names(&identity);
1057
1304
  attach_commands_for_runtime_windows(
1058
- state
1305
+ identity
1059
1306
  .get("tmux_endpoint")
1060
1307
  .and_then(serde_json::Value::as_str)
1308
+ .or_else(|| {
1309
+ identity.get("tmux_socket").and_then(serde_json::Value::as_str)
1310
+ })
1311
+ .or_else(|| {
1312
+ state
1313
+ .get("tmux_endpoint")
1314
+ .and_then(serde_json::Value::as_str)
1315
+ })
1061
1316
  .or_else(|| {
1062
1317
  state.get("tmux_socket").and_then(serde_json::Value::as_str)
1063
1318
  }),
@@ -1092,12 +1347,18 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
1092
1347
  }
1093
1348
  next_actions.extend(attach_commands.iter().cloned());
1094
1349
  }
1350
+ let agent_ids = identity
1351
+ .get("agents")
1352
+ .and_then(serde_json::Value::as_object)
1353
+ .map(|agents| agents.keys().cloned().collect::<Vec<_>>())
1354
+ .unwrap_or_default();
1095
1355
  return Ok(QuickStartReport::ExistingRuntime {
1096
1356
  team: requested_team.clone(),
1097
1357
  session_name,
1098
1358
  state_path: Some(state_path),
1099
1359
  next_actions,
1100
1360
  attach_commands,
1361
+ agent_ids,
1101
1362
  });
1102
1363
  }
1103
1364
  }
@@ -1113,6 +1374,14 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
1113
1374
  // team_key 已在入口由显式 id/name 或 compiled spec identity 单次确定。
1114
1375
  let state_team_key = canonical_team_key;
1115
1376
  warn_ignored_owner_team_id(workspace.as_path(), agents_dir, &state_team_key);
1377
+ if let Some((summary, blockers, next_actions)) = preflight_fresh_leader_identity(transport) {
1378
+ return Ok(QuickStartReport::PreflightBlocked {
1379
+ summary,
1380
+ blockers,
1381
+ next_actions,
1382
+ attach_commands: Vec::new(),
1383
+ });
1384
+ }
1116
1385
  // E5 spec 迁移:spec 写到 .team/runtime/<team_key>/(中间产物,绝不落用户目录 agents_dir)。
1117
1386
  // Bug2:原子写(tmp+rename),避免半截 spec。
1118
1387
  let spec_path = crate::model::paths::runtime_spec_path(&workspace, &state_team_key);
@@ -1144,6 +1413,13 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
1144
1413
  == Some("quick-start")
1145
1414
  })
1146
1415
  .cloned();
1416
+ let seeded_receiver = state
1417
+ .get("teams")
1418
+ .and_then(serde_json::Value::as_object)
1419
+ .and_then(|teams| teams.get(&state_team_key))
1420
+ .and_then(|team| team.get("leader_receiver"))
1421
+ .cloned()
1422
+ .or_else(|| state.get("leader_receiver").cloned());
1147
1423
  // 0.5.x Phase 1d hot-path 接线(裁决1 msg_76e1d98202b8): use the
1148
1424
  // generic annotator that writes `state.transport = { kind, source }`
1149
1425
  // for every backend AND (for tmux) preserves the existing
@@ -1180,12 +1456,20 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
1180
1456
  // Fresh initialization owns this one fail-closed bind attempt. It is
1181
1457
  // independent of display layout, so --no-display never suppresses receiver
1182
1458
  // binding. Readiness receives true only after canonical registry readback.
1459
+ let _ = take_fresh_bind_refusal();
1183
1460
  launch.leader_receiver_attached = bind_fresh_quick_start_leader(
1184
1461
  &workspace,
1185
1462
  &state_team_key,
1186
1463
  seeded_owner.as_ref(),
1464
+ seeded_receiver.as_ref(),
1187
1465
  transport,
1188
1466
  )?;
1467
+ if !launch.leader_receiver_attached {
1468
+ if let Some((stage, reason)) = take_fresh_bind_refusal() {
1469
+ launch.leader_bind_stage = Some(stage.to_string());
1470
+ launch.leader_bind_reason = Some(reason.to_string());
1471
+ }
1472
+ }
1189
1473
  launch.session_capture_incomplete_agents =
1190
1474
  quick_start_session_capture_incomplete_agents(&workspace, &state_team_key);
1191
1475
  let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
@@ -1258,6 +1542,7 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
1258
1542
  attach_commands,
1259
1543
  display_backend,
1260
1544
  worker_readiness,
1545
+ team: state_team_key,
1261
1546
  })
1262
1547
  }
1263
1548
 
@@ -1266,7 +1551,7 @@ mod fresh_quick_start_leader_binding_tests {
1266
1551
  use super::*;
1267
1552
  use crate::layout::worker_env::{CALLER_ENDPOINT_ENV, CALLER_PANE_ENV, CALLER_PROVIDER_ENV};
1268
1553
  use crate::transport::test_support::OfflineTransport;
1269
- use crate::transport::{PaneInfo, SessionName, WindowName};
1554
+ use crate::transport::{PaneId, PaneInfo, SessionName, WindowName};
1270
1555
  use serde_json::json;
1271
1556
  use std::collections::BTreeMap;
1272
1557
  use std::sync::atomic::{AtomicU64, Ordering};
@@ -1275,7 +1560,7 @@ mod fresh_quick_start_leader_binding_tests {
1275
1560
 
1276
1561
  static SEQUENCE: AtomicU64 = AtomicU64::new(0);
1277
1562
 
1278
- fn workspace(tag: &str) -> PathBuf {
1563
+ fn workspace_with_provider(tag: &str, provider: &str) -> PathBuf {
1279
1564
  let path = std::env::temp_dir().join(format!(
1280
1565
  "ta-quick-start-bind-{tag}-{}-{}",
1281
1566
  std::process::id(),
@@ -1288,14 +1573,19 @@ mod fresh_quick_start_leader_binding_tests {
1288
1573
  "active_team_key": "fresh",
1289
1574
  "team_key": "fresh",
1290
1575
  "session_name": "team-fresh",
1291
- "agents": {"sol": {"status": "running", "provider": "pi"}}
1576
+ "agents": {"sol": {"status": "running", "provider": provider}}
1292
1577
  }),
1293
1578
  )
1294
1579
  .unwrap();
1295
1580
  path
1296
1581
  }
1297
1582
 
1583
+ fn workspace(tag: &str) -> PathBuf {
1584
+ workspace_with_provider(tag, "codex")
1585
+ }
1586
+
1298
1587
  struct MockOps {
1588
+ provider: String,
1299
1589
  pane: Option<String>,
1300
1590
  explicit_provider: Option<String>,
1301
1591
  endpoint: Option<String>,
@@ -1308,15 +1598,20 @@ mod fresh_quick_start_leader_binding_tests {
1308
1598
  readback_calls: usize,
1309
1599
  attached_provider: Option<crate::provider::Provider>,
1310
1600
  applied_grant: Option<(serde_json::Value, serde_json::Value)>,
1601
+ frozen_owner: Option<serde_json::Value>,
1602
+ frozen_receiver: Option<serde_json::Value>,
1603
+ last_expected_receiver: Option<serde_json::Value>,
1604
+ extra_state_keys: Vec<&'static str>,
1311
1605
  }
1312
1606
 
1313
1607
  impl Default for MockOps {
1314
1608
  fn default() -> Self {
1315
1609
  Self {
1610
+ provider: "codex".to_string(),
1316
1611
  pane: Some("%42".to_string()),
1317
- explicit_provider: Some("pi".to_string()),
1612
+ explicit_provider: Some("codex".to_string()),
1318
1613
  endpoint: Some("/private/tmp/tmux-test/default".to_string()),
1319
- command: Some("pi".to_string()),
1614
+ command: Some("codex".to_string()),
1320
1615
  attach_ok: true,
1321
1616
  register_ok: true,
1322
1617
  readback_ok: true,
@@ -1325,55 +1620,22 @@ mod fresh_quick_start_leader_binding_tests {
1325
1620
  readback_calls: 0,
1326
1621
  attached_provider: None,
1327
1622
  applied_grant: None,
1623
+ frozen_owner: None,
1624
+ frozen_receiver: None,
1625
+ last_expected_receiver: None,
1626
+ extra_state_keys: Vec::new(),
1328
1627
  }
1329
1628
  }
1330
1629
  }
1331
1630
 
1332
- #[test]
1333
- fn invalid_pi_tool_category_is_rejected_before_runtime_persistence() {
1334
- let root = std::env::temp_dir().join(format!(
1335
- "ta-quick-start-pi-tools-{}-{}",
1336
- std::process::id(),
1337
- SEQUENCE.fetch_add(1, Ordering::Relaxed)
1338
- ));
1339
- let team = root.join(".team/current");
1340
- std::fs::create_dir_all(team.join("agents")).unwrap();
1341
- std::fs::write(
1342
- team.join("TEAM.md"),
1343
- "---\nname: pi-tools\nprovider: pi\n---\n\nPi team.\n",
1344
- )
1345
- .unwrap();
1346
- std::fs::write(
1347
- team.join("agents/worker.md"),
1348
- "---\nname: worker\nrole: Worker\nprovider: pi\nauth_mode: subscription\ndangerously_skip_permissions: false\ntools:\n - mcp_team\n - fs_read\n - provider_builtin\n---\n\nWorker.\n",
1349
- )
1350
- .unwrap();
1351
-
1352
- let transport = crate::transport::test_support::OfflineTransport::new();
1353
- let mut discover = |_requested: &str| -> Result<Vec<String>, ()> { Err(()) };
1354
- let error = quick_start_with_transport_in_workspace_with_display_pi_preflight(
1355
- &root,
1356
- &team,
1357
- None,
1358
- true,
1359
- None,
1360
- &transport,
1361
- false,
1362
- &mut discover,
1363
- )
1364
- .expect_err("invalid Pi category must fail before quick-start persistence");
1365
- let error = error.to_string();
1366
- assert!(error.contains("Pi does not support Team Agent tool category \"provider_builtin\""));
1367
- assert!(error.contains("remove it from the role's tools"));
1368
- assert!(
1369
- !crate::state::persist::runtime_state_path(&root).exists(),
1370
- "quick-start validation failure must not persist runtime state"
1371
- );
1372
- assert!(
1373
- !root.join(".team/runtime").exists(),
1374
- "quick-start validation failure must not create runtime artifacts"
1375
- );
1376
- let _ = std::fs::remove_dir_all(root);
1631
+ impl MockOps {
1632
+ fn with_provider(provider: &str) -> Self {
1633
+ let mut ops = Self::default();
1634
+ ops.provider = provider.to_string();
1635
+ ops.explicit_provider = Some(provider.to_string());
1636
+ ops.command = Some(provider.to_string());
1637
+ ops
1638
+ }
1377
1639
  }
1378
1640
 
1379
1641
  impl FreshQuickStartLeaderBindingOps for MockOps {
@@ -1406,6 +1668,7 @@ mod fresh_quick_start_leader_binding_tests {
1406
1668
  if !self.attach_ok {
1407
1669
  state["failed_attach_mutation"] = json!(true);
1408
1670
  let _ = crate::state::persist::save_runtime_state(workspace, state);
1671
+ self.extra_state_keys.push("failed_attach_mutation");
1409
1672
  return false;
1410
1673
  }
1411
1674
  let team_key = state
@@ -1413,12 +1676,19 @@ mod fresh_quick_start_leader_binding_tests {
1413
1676
  .and_then(serde_json::Value::as_str)
1414
1677
  .unwrap_or("fresh")
1415
1678
  .to_string();
1416
- let expected_owner = state.get("team_owner").cloned();
1417
- let expected_receiver = state.get("leader_receiver").cloned();
1679
+ let expected_owner = self
1680
+ .frozen_owner
1681
+ .clone()
1682
+ .or_else(|| state.get("team_owner").cloned());
1683
+ let expected_receiver = self
1684
+ .frozen_receiver
1685
+ .clone()
1686
+ .or_else(|| state.get("leader_receiver").cloned());
1687
+ self.last_expected_receiver = expected_receiver.clone();
1418
1688
  let owner = expected_owner.clone().unwrap_or_else(|| {
1419
1689
  json!({
1420
1690
  "pane_id": pane.as_str(),
1421
- "provider": "pi",
1691
+ "provider": self.provider.as_str(),
1422
1692
  "leader_session_uuid": "uuid-fresh",
1423
1693
  "owner_epoch": 1
1424
1694
  })
@@ -1431,7 +1701,7 @@ mod fresh_quick_start_leader_binding_tests {
1431
1701
  "mode": "direct_tmux",
1432
1702
  "pane_id": pane.as_str(),
1433
1703
  "status": "attached",
1434
- "provider": "pi",
1704
+ "provider": self.provider.as_str(),
1435
1705
  "leader_session_uuid": "uuid-fresh",
1436
1706
  "owner_epoch": owner_epoch,
1437
1707
  "tmux_socket": self.endpoint
@@ -1471,6 +1741,16 @@ mod fresh_quick_start_leader_binding_tests {
1471
1741
  self.applied_grant.clone()
1472
1742
  }
1473
1743
 
1744
+ fn frozen_seed_receipt(&self) -> Option<(serde_json::Value, serde_json::Value)> {
1745
+ self.frozen_owner
1746
+ .clone()
1747
+ .zip(self.frozen_receiver.clone())
1748
+ }
1749
+
1750
+ fn this_attempt_extra_state_keys(&self) -> Vec<&'static str> {
1751
+ self.extra_state_keys.clone()
1752
+ }
1753
+
1474
1754
  fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
1475
1755
  self.register_calls += 1;
1476
1756
  let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
@@ -1487,6 +1767,7 @@ mod fresh_quick_start_leader_binding_tests {
1487
1767
  if !self.register_ok {
1488
1768
  persisted["failed_registry_mutation"] = json!(true);
1489
1769
  crate::state::persist::save_runtime_state(workspace, &persisted).unwrap();
1770
+ self.extra_state_keys.push("failed_registry_mutation");
1490
1771
  }
1491
1772
  self.register_ok
1492
1773
  }
@@ -1497,6 +1778,7 @@ mod fresh_quick_start_leader_binding_tests {
1497
1778
  if !self.readback_ok {
1498
1779
  persisted["failed_readback_mutation"] = json!(true);
1499
1780
  crate::state::persist::save_runtime_state(workspace, &persisted).unwrap();
1781
+ self.extra_state_keys.push("failed_readback_mutation");
1500
1782
  }
1501
1783
  self.readback_ok
1502
1784
  && persisted
@@ -1509,12 +1791,12 @@ mod fresh_quick_start_leader_binding_tests {
1509
1791
  }
1510
1792
 
1511
1793
  #[test]
1512
- fn fresh_binding_persists_then_registers_then_requires_canonical_readback() {
1794
+ fn fresh_binding_persists_then_registers_then_requires_canonical_readback_provider_independent() {
1513
1795
  let workspace = workspace("positive");
1514
1796
  let mut ops = MockOps::default();
1515
1797
  assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
1516
1798
  .unwrap());
1517
- assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
1799
+ assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Codex));
1518
1800
  assert_eq!(ops.attach_calls, 1);
1519
1801
  assert_eq!(ops.register_calls, 1);
1520
1802
  assert_eq!(ops.readback_calls, 1);
@@ -1538,6 +1820,146 @@ mod fresh_quick_start_leader_binding_tests {
1538
1820
  );
1539
1821
  }
1540
1822
 
1823
+ fn seed_fresh_receipts(
1824
+ workspace: &std::path::Path,
1825
+ ) -> (serde_json::Value, serde_json::Value, serde_json::Value) {
1826
+ let mut state = crate::state::persist::load_runtime_state(workspace).unwrap();
1827
+ let caller = crate::state::owner_gate::CallerIdentity {
1828
+ pane_id: "%42".to_string(),
1829
+ provider: "codex".to_string(),
1830
+ machine_fingerprint: "fp".to_string(),
1831
+ leader_session_uuid: "uuid-fresh".to_string(),
1832
+ leader_session_uuid_source: "env".to_string(),
1833
+ };
1834
+ assert!(super::spec_state::seed_launched_owner_from_caller_with_provider_lookup(
1835
+ &mut state,
1836
+ caller,
1837
+ |_| None
1838
+ ));
1839
+ let owner = state
1840
+ .pointer("/teams/fresh/team_owner")
1841
+ .cloned()
1842
+ .expect("seed owner");
1843
+ let receiver = state
1844
+ .pointer("/teams/fresh/leader_receiver")
1845
+ .cloned()
1846
+ .expect("seed receiver");
1847
+ assert_eq!(
1848
+ receiver.get("status").and_then(serde_json::Value::as_str),
1849
+ Some("pending")
1850
+ );
1851
+ crate::state::persist::save_runtime_state(workspace, &state).unwrap();
1852
+ (state, owner, receiver)
1853
+ }
1854
+
1855
+ #[test]
1856
+ #[serial_test::serial(env)]
1857
+ fn fresh_seed_exemption_requires_frozen_receiver_receipt() {
1858
+ let workspace = workspace("frozen-seed");
1859
+ let (_state, owner, frozen_receiver) = seed_fresh_receipts(&workspace);
1860
+ let mut ops = MockOps {
1861
+ frozen_owner: Some(owner.clone()),
1862
+ frozen_receiver: Some(frozen_receiver.clone()),
1863
+ ..MockOps::default()
1864
+ };
1865
+ assert!(bind_fresh_quick_start_leader_with(
1866
+ &workspace,
1867
+ "fresh",
1868
+ Some(&owner),
1869
+ &mut ops
1870
+ )
1871
+ .unwrap());
1872
+ assert_eq!(ops.attach_calls, 1);
1873
+ assert_eq!(ops.last_expected_receiver, Some(frozen_receiver));
1874
+ let _ = std::fs::remove_dir_all(&workspace);
1875
+ }
1876
+
1877
+ #[test]
1878
+ #[serial_test::serial(env)]
1879
+ fn newer_pending_receiver_is_not_cas_expected() {
1880
+ let workspace = workspace("newer-pending");
1881
+ let (mut state, owner, frozen_receiver) = seed_fresh_receipts(&workspace);
1882
+ let mut newer = frozen_receiver.clone();
1883
+ newer["pane_id"] = json!("%99");
1884
+ newer["nonce"] = json!("winner");
1885
+ state["leader_receiver"] = newer.clone();
1886
+ state["teams"]["fresh"]["leader_receiver"] = newer.clone();
1887
+ crate::state::persist::save_runtime_state_with_receiver_authority(
1888
+ &workspace,
1889
+ &state,
1890
+ "fresh",
1891
+ None,
1892
+ )
1893
+ .unwrap();
1894
+ let on_disk = crate::state::persist::load_runtime_state(&workspace).unwrap();
1895
+ assert_eq!(
1896
+ on_disk
1897
+ .pointer("/teams/fresh/leader_receiver")
1898
+ .cloned()
1899
+ .as_ref(),
1900
+ Some(&newer),
1901
+ "pending winner must be on disk before bind"
1902
+ );
1903
+ let mut ops = MockOps {
1904
+ frozen_owner: Some(owner.clone()),
1905
+ frozen_receiver: Some(frozen_receiver),
1906
+ ..MockOps::default()
1907
+ };
1908
+ assert!(!bind_fresh_quick_start_leader_with(
1909
+ &workspace,
1910
+ "fresh",
1911
+ Some(&owner),
1912
+ &mut ops
1913
+ )
1914
+ .unwrap());
1915
+ assert_eq!(ops.attach_calls, 0);
1916
+ assert_eq!(ops.last_expected_receiver, None);
1917
+ let _ = std::fs::remove_dir_all(&workspace);
1918
+ }
1919
+
1920
+ #[test]
1921
+ #[serial_test::serial(env)]
1922
+ fn newer_attached_receiver_is_kept() {
1923
+ let workspace = workspace("newer-attached");
1924
+ let (mut state, owner, frozen_receiver) = seed_fresh_receipts(&workspace);
1925
+ let mut winner = frozen_receiver.clone();
1926
+ winner["status"] = json!("attached");
1927
+ winner["pane_id"] = json!("%99");
1928
+ state["leader_receiver"] = winner.clone();
1929
+ state["teams"]["fresh"]["leader_receiver"] = winner.clone();
1930
+ crate::state::persist::save_runtime_state_with_receiver_authority(
1931
+ &workspace,
1932
+ &state,
1933
+ "fresh",
1934
+ None,
1935
+ )
1936
+ .unwrap();
1937
+ let on_disk = crate::state::persist::load_runtime_state(&workspace).unwrap();
1938
+ assert_eq!(
1939
+ on_disk
1940
+ .pointer("/teams/fresh/leader_receiver")
1941
+ .cloned()
1942
+ .as_ref(),
1943
+ Some(&winner),
1944
+ "attached winner must be on disk before bind"
1945
+ );
1946
+ let mut ops = MockOps {
1947
+ frozen_owner: Some(owner.clone()),
1948
+ frozen_receiver: Some(frozen_receiver),
1949
+ ..MockOps::default()
1950
+ };
1951
+ assert!(!bind_fresh_quick_start_leader_with(
1952
+ &workspace,
1953
+ "fresh",
1954
+ Some(&owner),
1955
+ &mut ops
1956
+ )
1957
+ .unwrap());
1958
+ assert_eq!(ops.attach_calls, 0);
1959
+ assert_eq!(ops.last_expected_receiver, None);
1960
+ let _ = std::fs::remove_dir_all(&workspace);
1961
+ }
1962
+
1541
1963
  #[test]
1542
1964
  fn fresh_bind_refusal_event_keeps_first_safe_stage_and_reason() {
1543
1965
  for (case, expected_stage, expected_reason) in [
@@ -1671,16 +2093,18 @@ mod fresh_quick_start_leader_binding_tests {
1671
2093
  | "dual_state_mismatch" => {
1672
2094
  state["workspace"] = json!(workspace);
1673
2095
  state["team_owner"] = json!({
1674
- "pane_id": "%42", "provider": "pi",
2096
+ "pane_id": "%42", "provider": "codex",
1675
2097
  "leader_session_uuid": "uuid-fresh", "owner_epoch": 1
1676
2098
  });
1677
2099
  state["leader_receiver"] = json!({
1678
2100
  "mode": "direct_tmux", "status": "attached", "pane_id": "%42",
1679
- "provider": "pi", "leader_session_uuid": "uuid-fresh", "owner_epoch": 1,
2101
+ "provider": "codex", "leader_session_uuid": "uuid-fresh", "owner_epoch": 1,
1680
2102
  "tmux_socket": "/private/tmp/tmux-test/default"
1681
2103
  });
1682
2104
  match case {
1683
- "provider_mismatch" => state["leader_receiver"]["provider"] = json!("codex"),
2105
+ "provider_mismatch" => {
2106
+ state["leader_receiver"]["provider"] = json!("claude")
2107
+ },
1684
2108
  "socket_mismatch" => state["leader_receiver"]["tmux_socket"] = json!("/tmp/other"),
1685
2109
  "workspace_mismatch" => state["workspace"] = json!(workspace.join("other")),
1686
2110
  "dual_state_mismatch" => state["team_owner"]["owner_epoch"] = json!(2),
@@ -1739,7 +2163,7 @@ mod fresh_quick_start_leader_binding_tests {
1739
2163
  "active_team_key": "fresh",
1740
2164
  "team_key": "fresh",
1741
2165
  "session_name": "team-fresh",
1742
- "agents": {"sol": {"status": "running", "provider": "pi"}},
2166
+ "agents": {"sol": {"status": "running", "provider": "codex"}},
1743
2167
  "teams": {
1744
2168
  "current": {
1745
2169
  "team_key": "current",
@@ -1750,10 +2174,10 @@ mod fresh_quick_start_leader_binding_tests {
1750
2174
  "workspace": workspace,
1751
2175
  "team_key": "fresh",
1752
2176
  "session_name": "team-fresh",
1753
- "agents": {"sol": {"status": "running", "provider": "pi"}},
2177
+ "agents": {"sol": {"status": "running", "provider": "codex"}},
1754
2178
  "team_owner": {
1755
2179
  "pane_id": "%42",
1756
- "provider": "pi",
2180
+ "provider": "codex",
1757
2181
  "leader_session_uuid": "uuid-fresh",
1758
2182
  "owner_epoch": 1
1759
2183
  },
@@ -1761,7 +2185,7 @@ mod fresh_quick_start_leader_binding_tests {
1761
2185
  "mode": "direct_tmux",
1762
2186
  "status": "attached",
1763
2187
  "pane_id": "%42",
1764
- "provider": "pi",
2188
+ "provider": "codex",
1765
2189
  "leader_session_uuid": "uuid-fresh",
1766
2190
  "owner_epoch": 1,
1767
2191
  "tmux_socket": ambient
@@ -1787,7 +2211,7 @@ mod fresh_quick_start_leader_binding_tests {
1787
2211
  }
1788
2212
 
1789
2213
  fn explicit_provider(&mut self) -> Option<String> {
1790
- Some("pi".to_string())
2214
+ Some("codex".to_string())
1791
2215
  }
1792
2216
 
1793
2217
  fn tmux_endpoint(&mut self) -> Option<String> {
@@ -1795,7 +2219,7 @@ mod fresh_quick_start_leader_binding_tests {
1795
2219
  }
1796
2220
 
1797
2221
  fn observe_command(&mut self, _pane: &PaneId) -> Option<String> {
1798
- Some("pi".to_string())
2222
+ Some("codex".to_string())
1799
2223
  }
1800
2224
 
1801
2225
  fn attach(
@@ -1845,8 +2269,28 @@ mod fresh_quick_start_leader_binding_tests {
1845
2269
  attach_calls: 0,
1846
2270
  registry_receipt: None,
1847
2271
  };
1848
- assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
1849
- .unwrap());
2272
+ let endpoint = "/private/tmp/tmux-test/default";
2273
+ let transport = OfflineTransport::default()
2274
+ .with_tmux_endpoint(endpoint)
2275
+ .with_targets(vec![PaneInfo {
2276
+ pane_id: PaneId::new("%42"),
2277
+ session: SessionName::new("s"),
2278
+ window_index: None,
2279
+ window_name: None,
2280
+ pane_index: None,
2281
+ tty: None,
2282
+ current_command: Some("codex".to_string()),
2283
+ current_path: Some(workspace.clone()),
2284
+ active: true,
2285
+ pane_pid: None,
2286
+ leader_env: Default::default(),
2287
+ }]);
2288
+ assert!(crate::transport_factory::with_leader_endpoint_transport(
2289
+ endpoint,
2290
+ transport,
2291
+ || bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
2292
+ )
2293
+ .unwrap());
1850
2294
  assert_eq!(ops.attach_calls, 0, "preseeded dual state must not reattach");
1851
2295
  let path = crate::leader::registry::registry_dir().unwrap().join(format!(
1852
2296
  "{}__fresh.json",
@@ -1867,7 +2311,7 @@ mod fresh_quick_start_leader_binding_tests {
1867
2311
  state["workspace"] = json!(workspace);
1868
2312
  state["team_owner"] = json!({
1869
2313
  "pane_id": "%old",
1870
- "provider": "pi",
2314
+ "provider": "codex",
1871
2315
  "leader_session_uuid": "uuid-old",
1872
2316
  "owner_epoch": 4,
1873
2317
  "claimed_via": "quick-start"
@@ -1876,7 +2320,7 @@ mod fresh_quick_start_leader_binding_tests {
1876
2320
  "mode": "direct_tmux",
1877
2321
  "status": "attached",
1878
2322
  "pane_id": "%old",
1879
- "provider": "pi",
2323
+ "provider": "codex",
1880
2324
  "leader_session_uuid": "uuid-old",
1881
2325
  "owner_epoch": 4,
1882
2326
  "tmux_socket": "/private/tmp/tmux-test/old"
@@ -1897,7 +2341,7 @@ mod fresh_quick_start_leader_binding_tests {
1897
2341
  fn matching_seed() -> serde_json::Value {
1898
2342
  json!({
1899
2343
  "pane_id": "%42",
1900
- "provider": "pi",
2344
+ "provider": "codex",
1901
2345
  "leader_session_uuid": "uuid-fresh",
1902
2346
  "owner_epoch": 1,
1903
2347
  "claimed_via": "quick-start"
@@ -1909,16 +2353,29 @@ mod fresh_quick_start_leader_binding_tests {
1909
2353
  "mode": "direct_tmux",
1910
2354
  "status": "attached",
1911
2355
  "pane_id": "%42",
1912
- "provider": "pi",
2356
+ "provider": "codex",
1913
2357
  "leader_session_uuid": "uuid-fresh",
1914
2358
  "owner_epoch": 1,
1915
2359
  "tmux_socket": "/private/tmp/tmux-test/default"
1916
2360
  })
1917
2361
  }
1918
2362
 
1919
- fn workspace_with_matching_seed(tag: &str) -> (PathBuf, serde_json::Value) {
1920
- let workspace = workspace(tag);
1921
- let seed = matching_seed();
2363
+ fn matching_pending_receiver() -> serde_json::Value {
2364
+ json!({
2365
+ "mode": "direct_tmux",
2366
+ "status": "pending",
2367
+ "discovery": "quick_start_seed",
2368
+ "pane_id": "%42",
2369
+ "provider": "codex",
2370
+ "leader_session_uuid": "uuid-fresh",
2371
+ "owner_epoch": 1,
2372
+ "tmux_socket": "/private/tmp/tmux-test/default"
2373
+ })
2374
+ }
2375
+
2376
+ fn workspace_with_fresh_seed(tag: &str) -> (PathBuf, serde_json::Value) {
2377
+ let workspace = workspace(tag);
2378
+ let seed = matching_seed();
1922
2379
  crate::state::persist::save_runtime_state(
1923
2380
  &workspace,
1924
2381
  &json!({
@@ -1926,13 +2383,46 @@ mod fresh_quick_start_leader_binding_tests {
1926
2383
  "active_team_key": "fresh",
1927
2384
  "team_key": "fresh",
1928
2385
  "session_name": "team-fresh",
1929
- "agents": {"sol": {"status": "running", "provider": "pi"}},
2386
+ "agents": {"sol": {"status": "running", "provider": "codex"}},
2387
+ "team_owner": seed,
2388
+ "leader_receiver": matching_pending_receiver(),
1930
2389
  "teams": {
1931
2390
  "fresh": {
1932
2391
  "workspace": workspace,
1933
2392
  "team_key": "fresh",
1934
2393
  "session_name": "team-fresh",
1935
- "agents": {"sol": {"status": "running", "provider": "pi"}},
2394
+ "agents": {"sol": {"status": "running", "provider": "codex"}},
2395
+ "team_owner": seed,
2396
+ "leader_receiver": matching_pending_receiver(),
2397
+ "owner_epoch": 1
2398
+ }
2399
+ }
2400
+ }),
2401
+ )
2402
+ .unwrap();
2403
+ let seed = crate::state::persist::load_runtime_state(&workspace).unwrap()["teams"]["fresh"]
2404
+ ["team_owner"]
2405
+ .clone();
2406
+ (workspace, seed)
2407
+ }
2408
+
2409
+ fn workspace_with_matching_seed(tag: &str) -> (PathBuf, serde_json::Value) {
2410
+ let workspace = workspace(tag);
2411
+ let seed = matching_seed();
2412
+ crate::state::persist::save_runtime_state(
2413
+ &workspace,
2414
+ &json!({
2415
+ "workspace": workspace,
2416
+ "active_team_key": "fresh",
2417
+ "team_key": "fresh",
2418
+ "session_name": "team-fresh",
2419
+ "agents": {"sol": {"status": "running", "provider": "codex"}},
2420
+ "teams": {
2421
+ "fresh": {
2422
+ "workspace": workspace,
2423
+ "team_key": "fresh",
2424
+ "session_name": "team-fresh",
2425
+ "agents": {"sol": {"status": "running", "provider": "codex"}},
1936
2426
  "team_owner": seed,
1937
2427
  "leader_receiver": matching_receiver(),
1938
2428
  "owner_epoch": 1
@@ -2078,7 +2568,7 @@ mod fresh_quick_start_leader_binding_tests {
2078
2568
 
2079
2569
  #[test]
2080
2570
  fn seeded_cleanup_clears_exact_seed_through_bind_repository_entry() {
2081
- let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-success");
2571
+ let (workspace, seed) = workspace_with_fresh_seed("seed-cleanup-success");
2082
2572
  let mut ops = failed_register_ops(None, false);
2083
2573
  assert!(!bind_fresh_quick_start_leader_with(
2084
2574
  &workspace,
@@ -2094,7 +2584,8 @@ mod fresh_quick_start_leader_binding_tests {
2094
2584
  .applied_grant
2095
2585
  .as_ref()
2096
2586
  .is_some_and(|(owner, receiver)| {
2097
- owner == &seed && receiver == &matching_receiver()
2587
+ owner == &seed
2588
+ && receiver.get("pane_id").and_then(serde_json::Value::as_str) == Some("%42")
2098
2589
  }));
2099
2590
  let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
2100
2591
  assert!(
@@ -2154,7 +2645,7 @@ mod fresh_quick_start_leader_binding_tests {
2154
2645
 
2155
2646
  #[test]
2156
2647
  fn seeded_cleanup_propagates_persist_error() {
2157
- let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-persist-err");
2648
+ let (workspace, seed) = workspace_with_fresh_seed("seed-cleanup-persist-err");
2158
2649
  let mut ops = failed_register_ops(None, true);
2159
2650
  let error = bind_fresh_quick_start_leader_with(
2160
2651
  &workspace,
@@ -2170,7 +2661,8 @@ mod fresh_quick_start_leader_binding_tests {
2170
2661
  .applied_grant
2171
2662
  .as_ref()
2172
2663
  .is_some_and(|(owner, receiver)| {
2173
- owner == &seed && receiver == &matching_receiver()
2664
+ owner == &seed
2665
+ && receiver.get("pane_id").and_then(serde_json::Value::as_str) == Some("%42")
2174
2666
  }));
2175
2667
  match error {
2176
2668
  LifecycleError::StatePersist(text) => {
@@ -2184,60 +2676,375 @@ mod fresh_quick_start_leader_binding_tests {
2184
2676
  let _ = std::fs::remove_dir_all(crate::state::persist::runtime_state_path(&workspace));
2185
2677
  }
2186
2678
 
2187
- #[test]
2188
- fn attach_registry_or_readback_failure_rolls_back_exact_state_bytes() {
2189
- for (case, mut ops) in [
2190
- (
2191
- "attach-failure",
2192
- MockOps {
2193
- attach_ok: false,
2194
- ..MockOps::default()
2195
- },
2196
- ),
2197
- (
2198
- "registry-failure",
2199
- MockOps {
2200
- register_ok: false,
2201
- ..MockOps::default()
2202
- },
2203
- ),
2204
- (
2205
- "readback-failure",
2206
- MockOps {
2207
- readback_ok: false,
2208
- ..MockOps::default()
2209
- },
2210
- ),
2679
+ fn complete_normalized_state() -> serde_json::Value {
2680
+ json!({
2681
+ "active_team_key": "fresh",
2682
+ "team_key": "fresh",
2683
+ "session_name": "team-fresh",
2684
+ "agents": {
2685
+ "sol": {
2686
+ "status": "running",
2687
+ "provider": "codex",
2688
+ "session_id": null,
2689
+ "rollout_path": null,
2690
+ "captured_at": null,
2691
+ "captured_via": null,
2692
+ "attribution_confidence": null,
2693
+ "spawn_cwd": null
2694
+ }
2695
+ }
2696
+ })
2697
+ }
2698
+
2699
+ fn persist_complete_normalized_baseline(workspace: &Path) -> (Vec<u8>, serde_json::Value) {
2700
+ let state = complete_normalized_state();
2701
+ let path = crate::state::persist::runtime_state_path(workspace);
2702
+ if let Some(parent) = path.parent() {
2703
+ std::fs::create_dir_all(parent).unwrap();
2704
+ }
2705
+ std::fs::write(&path, serde_json::to_string_pretty(&state).unwrap()).unwrap();
2706
+ let _ = crate::state::persist::load_runtime_state(workspace).unwrap();
2707
+ let raw = std::fs::read(&path).unwrap();
2708
+ let on_disk: serde_json::Value = serde_json::from_slice(&raw).unwrap();
2709
+ for field in [
2710
+ "session_id",
2711
+ "rollout_path",
2712
+ "captured_at",
2713
+ "captured_via",
2714
+ "attribution_confidence",
2715
+ "spawn_cwd",
2211
2716
  ] {
2212
- let workspace = workspace(case);
2213
- let state_path = crate::state::persist::runtime_state_path(&workspace);
2214
- let before = std::fs::read(&state_path).unwrap();
2215
- assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
2216
- .unwrap());
2217
2717
  assert_eq!(
2218
- std::fs::read(&state_path).unwrap(),
2219
- before,
2220
- "{case} left partial state bytes"
2718
+ on_disk.pointer(&format!("/agents/sol/{field}")),
2719
+ Some(&json!(null)),
2720
+ "normalized baseline missing {field}"
2221
2721
  );
2222
- match case {
2223
- "attach-failure" => {
2224
- assert_eq!(ops.attach_calls, 1);
2225
- assert_eq!(ops.register_calls, 0);
2226
- assert_eq!(ops.readback_calls, 0);
2227
- }
2228
- "registry-failure" => {
2229
- assert_eq!(ops.attach_calls, 1);
2230
- assert_eq!(ops.register_calls, 1);
2231
- assert_eq!(ops.readback_calls, 0);
2232
- }
2233
- "readback-failure" => {
2234
- assert_eq!(ops.attach_calls, 1);
2235
- assert_eq!(ops.register_calls, 1);
2236
- assert_eq!(ops.readback_calls, 1);
2237
- }
2238
- _ => unreachable!(),
2239
- }
2240
2722
  }
2723
+ assert_eq!(on_disk.get("team_key"), Some(&json!("fresh")));
2724
+ (raw, on_disk)
2725
+ }
2726
+
2727
+ fn clear_exact_expected_bytes(baseline: &serde_json::Value) -> Vec<u8> {
2728
+ let mut expected = baseline.clone();
2729
+ expected["teams"] = json!({
2730
+ "fresh": {
2731
+ "leader_receiver": null,
2732
+ "team_owner": null,
2733
+ "owner_epoch": 1
2734
+ }
2735
+ });
2736
+ serde_json::to_string_pretty(&expected).unwrap().into_bytes()
2737
+ }
2738
+
2739
+ fn assert_failure_rolls_back_this_attempt(
2740
+ case: &str,
2741
+ mut ops: MockOps,
2742
+ attach_calls: usize,
2743
+ register_calls: usize,
2744
+ readback_calls: usize,
2745
+ clear_exact: bool,
2746
+ ) {
2747
+ let workspace = workspace(case);
2748
+ let state_path = crate::state::persist::runtime_state_path(&workspace);
2749
+ let (before, baseline) = persist_complete_normalized_baseline(&workspace);
2750
+ let expected = if clear_exact {
2751
+ clear_exact_expected_bytes(&baseline)
2752
+ } else {
2753
+ before
2754
+ };
2755
+ assert!(
2756
+ !bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap(),
2757
+ "{case} must refuse"
2758
+ );
2759
+ assert_eq!(ops.attach_calls, attach_calls, "{case} attach_calls");
2760
+ assert_eq!(ops.register_calls, register_calls, "{case} register_calls");
2761
+ assert_eq!(ops.readback_calls, readback_calls, "{case} readback_calls");
2762
+ let after = crate::state::persist::load_runtime_state(&workspace).unwrap();
2763
+ assert!(
2764
+ after.get("failed_attach_mutation").is_none(),
2765
+ "{case} left failed_attach_mutation"
2766
+ );
2767
+ assert!(
2768
+ after.get("failed_registry_mutation").is_none(),
2769
+ "{case} left failed_registry_mutation"
2770
+ );
2771
+ assert!(
2772
+ after.get("failed_readback_mutation").is_none(),
2773
+ "{case} left failed_readback_mutation"
2774
+ );
2775
+ assert!(
2776
+ after
2777
+ .pointer("/teams/fresh/leader_receiver")
2778
+ .is_none_or(serde_json::Value::is_null),
2779
+ "{case} left a live leader_receiver"
2780
+ );
2781
+ assert!(
2782
+ after
2783
+ .pointer("/teams/fresh/team_owner")
2784
+ .is_none_or(serde_json::Value::is_null),
2785
+ "{case} left a live team_owner"
2786
+ );
2787
+ assert_eq!(
2788
+ std::fs::read(&state_path).unwrap(),
2789
+ expected,
2790
+ "{case} left partial state bytes"
2791
+ );
2792
+ let _ = std::fs::remove_dir_all(&workspace);
2793
+ }
2794
+
2795
+ #[test]
2796
+ fn attach_registry_or_readback_failure_rolls_back_exact_state_bytes() {
2797
+ assert_failure_rolls_back_this_attempt(
2798
+ "attach-failure",
2799
+ MockOps {
2800
+ attach_ok: false,
2801
+ ..MockOps::default()
2802
+ },
2803
+ 1,
2804
+ 0,
2805
+ 0,
2806
+ false,
2807
+ );
2808
+ assert_failure_rolls_back_this_attempt(
2809
+ "registry-failure",
2810
+ MockOps {
2811
+ register_ok: false,
2812
+ ..MockOps::default()
2813
+ },
2814
+ 1,
2815
+ 1,
2816
+ 0,
2817
+ true,
2818
+ );
2819
+ assert_failure_rolls_back_this_attempt(
2820
+ "readback-failure",
2821
+ MockOps {
2822
+ readback_ok: false,
2823
+ ..MockOps::default()
2824
+ },
2825
+ 1,
2826
+ 1,
2827
+ 1,
2828
+ true,
2829
+ );
2830
+ }
2831
+
2832
+ #[test]
2833
+ fn attach_failure_rolls_back_exact_state_bytes() {
2834
+ assert_failure_rolls_back_this_attempt(
2835
+ "attach-failure-only",
2836
+ MockOps {
2837
+ attach_ok: false,
2838
+ ..MockOps::default()
2839
+ },
2840
+ 1,
2841
+ 0,
2842
+ 0,
2843
+ false,
2844
+ );
2845
+ }
2846
+
2847
+ #[test]
2848
+ fn registry_failure_rolls_back_exact_state_bytes() {
2849
+ assert_failure_rolls_back_this_attempt(
2850
+ "registry-failure-only",
2851
+ MockOps {
2852
+ register_ok: false,
2853
+ ..MockOps::default()
2854
+ },
2855
+ 1,
2856
+ 1,
2857
+ 0,
2858
+ true,
2859
+ );
2860
+ }
2861
+
2862
+ #[test]
2863
+ fn attach_failure_keeps_unrelated_fields() {
2864
+ let workspace = workspace("unrelated-fields");
2865
+ let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
2866
+ state["tasks"] = json!({"keep": true});
2867
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
2868
+ let mut ops = MockOps {
2869
+ attach_ok: false,
2870
+ ..MockOps::default()
2871
+ };
2872
+ assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
2873
+ .unwrap());
2874
+ let after = crate::state::persist::load_runtime_state(&workspace).unwrap();
2875
+ assert_eq!(after.get("tasks"), Some(&json!({"keep": true})));
2876
+ assert!(after.get("failed_attach_mutation").is_none());
2877
+ let _ = std::fs::remove_dir_all(&workspace);
2878
+ }
2879
+
2880
+ fn complete_owner(pane: &str, provider: &str, uuid: &str, epoch: u64) -> serde_json::Value {
2881
+ json!({
2882
+ "pane_id": pane,
2883
+ "provider": provider,
2884
+ "leader_session_uuid": uuid,
2885
+ "owner_epoch": epoch
2886
+ })
2887
+ }
2888
+
2889
+ fn complete_receiver(
2890
+ pane: &str,
2891
+ provider: &str,
2892
+ uuid: &str,
2893
+ epoch: u64,
2894
+ socket: &str,
2895
+ ) -> serde_json::Value {
2896
+ json!({
2897
+ "mode": "direct_tmux",
2898
+ "status": "attached",
2899
+ "pane_id": pane,
2900
+ "provider": provider,
2901
+ "leader_session_uuid": uuid,
2902
+ "owner_epoch": epoch,
2903
+ "tmux_socket": socket
2904
+ })
2905
+ }
2906
+
2907
+ fn commit_fresh_binding(
2908
+ workspace: &Path,
2909
+ owner: &serde_json::Value,
2910
+ receiver: &serde_json::Value,
2911
+ epoch: u64,
2912
+ ) {
2913
+ let mut state = crate::state::persist::load_runtime_state(workspace).unwrap();
2914
+ state["teams"]["fresh"]["team_owner"] = owner.clone();
2915
+ state["teams"]["fresh"]["leader_receiver"] = receiver.clone();
2916
+ state["teams"]["fresh"]["owner_epoch"] = json!(epoch);
2917
+ crate::state::persist::save_runtime_state_with_receiver_authority(
2918
+ workspace,
2919
+ &state,
2920
+ "fresh",
2921
+ None,
2922
+ )
2923
+ .unwrap();
2924
+ let raw = std::fs::read(crate::state::persist::runtime_state_path(workspace)).unwrap();
2925
+ let on_disk: serde_json::Value = serde_json::from_slice(&raw).unwrap();
2926
+ assert_eq!(
2927
+ on_disk.pointer("/teams/fresh/team_owner"),
2928
+ Some(owner),
2929
+ "committed owner must equal predetermined pair"
2930
+ );
2931
+ assert_eq!(
2932
+ on_disk.pointer("/teams/fresh/leader_receiver"),
2933
+ Some(receiver),
2934
+ "committed receiver must equal predetermined pair"
2935
+ );
2936
+ assert_eq!(
2937
+ on_disk.pointer("/teams/fresh/owner_epoch"),
2938
+ Some(&json!(epoch)),
2939
+ "canonical epoch must match the pair"
2940
+ );
2941
+ }
2942
+
2943
+ #[test]
2944
+ fn restore_exact_restores_previous_owner_receiver() {
2945
+ let workspace = workspace("restore-exact-prev");
2946
+ let _ = persist_complete_normalized_baseline(&workspace);
2947
+ let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
2948
+ state["tasks"] = json!({"keep": true});
2949
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
2950
+ let previous_uuid = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
2951
+ let previous_owner = complete_owner("%99", "codex", previous_uuid, 1);
2952
+ let previous_receiver =
2953
+ complete_receiver("%99", "codex", previous_uuid, 1, "/tmp/prev.sock");
2954
+ commit_fresh_binding(&workspace, &previous_owner, &previous_receiver, 1);
2955
+ let snapshot = super::BindingFileSnapshot::capture(
2956
+ crate::state::persist::runtime_state_path(&workspace),
2957
+ );
2958
+ let this_uuid = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
2959
+ let this_owner = complete_owner("%42", "codex", this_uuid, 1);
2960
+ let this_receiver = complete_receiver(
2961
+ "%42",
2962
+ "codex",
2963
+ this_uuid,
2964
+ 1,
2965
+ "/private/tmp/tmux-test/default",
2966
+ );
2967
+ commit_fresh_binding(&workspace, &this_owner, &this_receiver, 1);
2968
+ let this_receipt = (this_owner.clone(), this_receiver.clone());
2969
+ assert_ne!(this_owner, previous_owner);
2970
+ assert_ne!(this_receiver, previous_receiver);
2971
+ super::restore_this_attempt_state(
2972
+ &workspace,
2973
+ "fresh",
2974
+ Some(&snapshot),
2975
+ Some(&this_receipt),
2976
+ &[],
2977
+ )
2978
+ .unwrap();
2979
+ let after = crate::state::persist::load_runtime_state(&workspace).unwrap();
2980
+ assert_eq!(after["teams"]["fresh"]["team_owner"], previous_owner);
2981
+ assert_eq!(after["teams"]["fresh"]["leader_receiver"], previous_receiver);
2982
+ assert_eq!(after["teams"]["fresh"]["owner_epoch"], json!(1));
2983
+ assert_eq!(after.get("tasks"), Some(&json!({"keep": true})));
2984
+ let _ = std::fs::remove_dir_all(&workspace);
2985
+ }
2986
+
2987
+ #[test]
2988
+ fn restore_exact_keeps_unmatched_winner() {
2989
+ let workspace = workspace("restore-exact-winner");
2990
+ let _ = persist_complete_normalized_baseline(&workspace);
2991
+ let previous_uuid = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
2992
+ let previous_owner = complete_owner("%99", "codex", previous_uuid, 1);
2993
+ let previous_receiver =
2994
+ complete_receiver("%99", "codex", previous_uuid, 1, "/tmp/prev.sock");
2995
+ commit_fresh_binding(&workspace, &previous_owner, &previous_receiver, 1);
2996
+ let snapshot = super::BindingFileSnapshot::capture(
2997
+ crate::state::persist::runtime_state_path(&workspace),
2998
+ );
2999
+ let this_uuid = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
3000
+ let this_owner = complete_owner("%42", "codex", this_uuid, 1);
3001
+ let this_receiver = complete_receiver(
3002
+ "%42",
3003
+ "codex",
3004
+ this_uuid,
3005
+ 1,
3006
+ "/private/tmp/tmux-test/default",
3007
+ );
3008
+ commit_fresh_binding(&workspace, &this_owner, &this_receiver, 1);
3009
+ let this_receipt = (this_owner.clone(), this_receiver.clone());
3010
+ let winner_uuid = "cccccccccccccccccccccccccccccccc";
3011
+ let winner_owner = complete_owner("%7", "codex", winner_uuid, 2);
3012
+ let winner_receiver = complete_receiver("%7", "codex", winner_uuid, 2, "/tmp/win.sock");
3013
+ commit_fresh_binding(&workspace, &winner_owner, &winner_receiver, 2);
3014
+ assert_ne!(
3015
+ (winner_owner.clone(), winner_receiver.clone()),
3016
+ this_receipt
3017
+ );
3018
+ super::restore_this_attempt_state(
3019
+ &workspace,
3020
+ "fresh",
3021
+ Some(&snapshot),
3022
+ Some(&this_receipt),
3023
+ &[],
3024
+ )
3025
+ .unwrap();
3026
+ let after = crate::state::persist::load_runtime_state(&workspace).unwrap();
3027
+ assert_eq!(after["teams"]["fresh"]["team_owner"], winner_owner);
3028
+ assert_eq!(after["teams"]["fresh"]["leader_receiver"], winner_receiver);
3029
+ assert_eq!(after["teams"]["fresh"]["owner_epoch"], json!(2));
3030
+ assert_ne!(after["teams"]["fresh"]["team_owner"], previous_owner);
3031
+ assert_ne!(after["teams"]["fresh"]["leader_receiver"], previous_receiver);
3032
+ let _ = std::fs::remove_dir_all(&workspace);
3033
+ }
3034
+
3035
+ #[test]
3036
+ fn readback_failure_rolls_back_exact_state_bytes() {
3037
+ assert_failure_rolls_back_this_attempt(
3038
+ "readback-failure-only",
3039
+ MockOps {
3040
+ readback_ok: false,
3041
+ ..MockOps::default()
3042
+ },
3043
+ 1,
3044
+ 1,
3045
+ 1,
3046
+ true,
3047
+ );
2241
3048
  }
2242
3049
 
2243
3050
  #[test]
@@ -2296,6 +3103,8 @@ mod fresh_quick_start_leader_binding_tests {
2296
3103
  cas_conflict: false,
2297
3104
  applied_grant: None,
2298
3105
  registry_receipt: None,
3106
+ frozen_owner: None,
3107
+ frozen_receiver: None,
2299
3108
  nonce_writer: None,
2300
3109
  },
2301
3110
  attached_provider: None,
@@ -2362,7 +3171,11 @@ mod fresh_quick_start_leader_binding_tests {
2362
3171
  }
2363
3172
  }
2364
3173
 
2365
- fn runtime_workspace(hermetic: &HermeticTestEnv, tag: &str) -> PathBuf {
3174
+ fn runtime_workspace_with_provider(
3175
+ hermetic: &HermeticTestEnv,
3176
+ tag: &str,
3177
+ provider: &str,
3178
+ ) -> PathBuf {
2366
3179
  let path = hermetic.workspace(tag);
2367
3180
  crate::state::persist::save_runtime_state(
2368
3181
  &path,
@@ -2370,13 +3183,17 @@ mod fresh_quick_start_leader_binding_tests {
2370
3183
  "active_team_key": "fresh",
2371
3184
  "team_key": "fresh",
2372
3185
  "session_name": "team-fresh",
2373
- "agents": {"sol": {"status": "running", "provider": "pi"}}
3186
+ "agents": {"sol": {"status": "running", "provider": provider}}
2374
3187
  }),
2375
3188
  )
2376
3189
  .unwrap();
2377
3190
  path
2378
3191
  }
2379
3192
 
3193
+ fn runtime_workspace(hermetic: &HermeticTestEnv, tag: &str) -> PathBuf {
3194
+ runtime_workspace_with_provider(hermetic, tag, "codex")
3195
+ }
3196
+
2380
3197
  fn refusal_reason(workspace: &Path) -> Option<(String, String)> {
2381
3198
  let events = crate::event_log::EventLog::new(workspace).tail(0).unwrap();
2382
3199
  let event = events.last()?;
@@ -2429,23 +3246,169 @@ mod fresh_quick_start_leader_binding_tests {
2429
3246
  }
2430
3247
  }
2431
3248
 
3249
+ fn bind_seeded_caller(target: PaneInfo, hermetic_name: &str) -> (bool, Option<&'static str>) {
3250
+ let hermetic = HermeticTestEnv::enter(hermetic_name);
3251
+ let workspace = runtime_workspace(&hermetic, "fresh");
3252
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3253
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
3254
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
3255
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
3256
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
3257
+ let seed = seeded_runtime_owner(&workspace);
3258
+ let transport = OfflineTransport::new()
3259
+ .with_tmux_endpoint("/tmp/tmux.sock")
3260
+ .with_pane_current_command("%1", "bash")
3261
+ .with_targets(vec![target]);
3262
+ let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
3263
+ transport: &transport,
3264
+ last_failure_reason: None,
3265
+ cas_conflict: false,
3266
+ applied_grant: None,
3267
+ registry_receipt: None,
3268
+ frozen_owner: None,
3269
+ frozen_receiver: None,
3270
+ nonce_writer: None,
3271
+ };
3272
+ let ok = crate::transport_factory::with_leader_endpoint_transport(
3273
+ "/tmp/tmux.sock",
3274
+ transport.clone(),
3275
+ || bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops),
3276
+ )
3277
+ .unwrap();
3278
+ (ok, ops.last_failure_reason)
3279
+ }
3280
+
2432
3281
  #[test]
2433
3282
  #[serial_test::serial(env)]
2434
- fn runtime_valid_caller_binds_typed_pi_through_bash() {
2435
- let hermetic = HermeticTestEnv::enter("runtime-valid-pi-bash");
3283
+ fn seeded_runtime_bind_ignores_pane_focus_for_same_live_caller() {
3284
+ let parent = std::env::temp_dir().join(format!(
3285
+ "ta-inactive-cwd-{}-{}",
3286
+ std::process::id(),
3287
+ std::time::SystemTime::now()
3288
+ .duration_since(std::time::UNIX_EPOCH)
3289
+ .unwrap()
3290
+ .as_nanos()
3291
+ ));
3292
+ std::fs::create_dir_all(&parent).unwrap();
3293
+ let mut focused = caller_target(parent.clone(), Some("shared-live-nonce"));
3294
+ focused.active = true;
3295
+ let (active_ok, active_reason) = bind_seeded_caller(focused, "runtime-focus-active");
3296
+ assert!(active_ok, "focused live caller must bind: {active_reason:?}");
3297
+
3298
+ let mut background = caller_target(parent, Some("shared-live-nonce"));
3299
+ background.active = false;
3300
+ let (inactive_ok, inactive_reason) =
3301
+ bind_seeded_caller(background, "runtime-focus-inactive");
3302
+ assert!(
3303
+ inactive_ok,
3304
+ "same live caller with pane_active=0 must still bind: {inactive_reason:?}"
3305
+ );
3306
+ }
3307
+
3308
+ #[test]
3309
+ #[serial_test::serial(env)]
3310
+ fn seeded_runtime_bind_dead_pane_still_refuses() {
3311
+ let hermetic = HermeticTestEnv::enter("runtime-dead-pane");
2436
3312
  let workspace = runtime_workspace(&hermetic, "fresh");
2437
3313
  let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
2438
3314
  let _pane = hermetic.with_env("TMUX_PANE", "%1");
2439
- let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
3315
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
2440
3316
  let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
2441
3317
  let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
3318
+ let seed = seeded_runtime_owner(&workspace);
2442
3319
  let transport = OfflineTransport::new()
2443
3320
  .with_tmux_endpoint("/tmp/tmux.sock")
2444
3321
  .with_pane_current_command("%1", "bash");
2445
- let mut ops = RecordingRuntimeOps::new(&transport);
2446
- assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
2447
- assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
2448
- assert_eq!(ops.attach_calls, 1);
3322
+ let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
3323
+ transport: &transport,
3324
+ last_failure_reason: None,
3325
+ cas_conflict: false,
3326
+ applied_grant: None,
3327
+ registry_receipt: None,
3328
+ frozen_owner: None,
3329
+ frozen_receiver: None,
3330
+ nonce_writer: None,
3331
+ };
3332
+ assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
3333
+ .unwrap());
3334
+ assert_eq!(ops.last_failure_reason, Some("caller_pane_not_live"));
3335
+ }
3336
+
3337
+ #[test]
3338
+ #[serial_test::serial(env)]
3339
+ fn seeded_runtime_bind_identity_mismatch_still_refuses() {
3340
+ let parent = std::env::temp_dir().join(format!(
3341
+ "ta-mismatch-cwd-{}-{}",
3342
+ std::process::id(),
3343
+ std::time::SystemTime::now()
3344
+ .duration_since(std::time::UNIX_EPOCH)
3345
+ .unwrap()
3346
+ .as_nanos()
3347
+ ));
3348
+ std::fs::create_dir_all(&parent).unwrap();
3349
+ let mut other_pane = caller_target(parent, Some("shared-live-nonce"));
3350
+ other_pane.pane_id = PaneId::new("%9");
3351
+ let (ok, reason) = bind_seeded_caller(other_pane, "runtime-identity-mismatch");
3352
+ assert!(!ok);
3353
+ assert_eq!(reason, Some("caller_pane_not_live"));
3354
+ }
3355
+
3356
+ #[test]
3357
+ fn existing_runtime_view_uses_requested_bob_not_top_level_alice() {
3358
+ let state = json!({
3359
+ "agents": { "alice": { "window": "alice" } },
3360
+ "session_name": "team-alice",
3361
+ "tmux_endpoint": "/tmp/shared.sock",
3362
+ "teams": {
3363
+ "alice": {
3364
+ "agents": { "alice": { "window": "alice" } },
3365
+ "session_name": "team-alice"
3366
+ },
3367
+ "bob": {
3368
+ "agents": { "bob": { "window": "bob" } },
3369
+ "session_name": "team-bob"
3370
+ }
3371
+ }
3372
+ });
3373
+ let view = existing_runtime_identity_view(&state, Some("bob"));
3374
+ let ids = view
3375
+ .get("agents")
3376
+ .and_then(serde_json::Value::as_object)
3377
+ .map(|agents| agents.keys().cloned().collect::<Vec<_>>())
3378
+ .unwrap_or_default();
3379
+ assert_eq!(ids, vec!["bob".to_string()]);
3380
+ assert_eq!(view.get("session_name").and_then(serde_json::Value::as_str), Some("team-bob"));
3381
+ }
3382
+
3383
+ #[test]
3384
+ fn existing_runtime_view_empty_top_level_agents_still_emits_bob() {
3385
+ let state = json!({
3386
+ "agents": {},
3387
+ "teams": {
3388
+ "bob": {
3389
+ "agents": { "bob": { "window": "bob" } },
3390
+ "session_name": "team-bob"
3391
+ }
3392
+ }
3393
+ });
3394
+ let view = existing_runtime_identity_view(&state, Some("bob"));
3395
+ assert!(view
3396
+ .get("agents")
3397
+ .and_then(serde_json::Value::as_object)
3398
+ .is_some_and(|agents| agents.contains_key("bob")));
3399
+ }
3400
+
3401
+ #[test]
3402
+ fn existing_runtime_view_legacy_root_without_teams_map() {
3403
+ let state = json!({
3404
+ "agents": { "alice": { "window": "alice" } },
3405
+ "session_name": "team-alice"
3406
+ });
3407
+ let view = existing_runtime_identity_view(&state, Some("alice"));
3408
+ assert!(view
3409
+ .get("agents")
3410
+ .and_then(serde_json::Value::as_object)
3411
+ .is_some_and(|agents| agents.contains_key("alice")));
2449
3412
  }
2450
3413
 
2451
3414
  #[test]
@@ -2457,7 +3420,7 @@ mod fresh_quick_start_leader_binding_tests {
2457
3420
  std::fs::create_dir_all(&parent).unwrap();
2458
3421
  let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
2459
3422
  let _pane = hermetic.with_env("TMUX_PANE", "%1");
2460
- let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
3423
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
2461
3424
  let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
2462
3425
  let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
2463
3426
  let seed = seeded_runtime_owner(&workspace);
@@ -2471,14 +3434,15 @@ mod fresh_quick_start_leader_binding_tests {
2471
3434
  last_failure_reason: None,
2472
3435
  cas_conflict: false,
2473
3436
  applied_grant: None,
2474
- registry_receipt: None,
2475
- nonce_writer: None,
2476
- };
2477
- assert!(bind_fresh_quick_start_leader_with(
2478
- &workspace,
2479
- "fresh",
2480
- Some(&seed),
2481
- &mut ops
3437
+ registry_receipt: None,
3438
+ frozen_owner: None,
3439
+ frozen_receiver: None,
3440
+ nonce_writer: None,
3441
+ };
3442
+ assert!(crate::transport_factory::with_leader_endpoint_transport(
3443
+ "/tmp/tmux.sock",
3444
+ transport.clone(),
3445
+ || bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
2482
3446
  )
2483
3447
  .unwrap());
2484
3448
  let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
@@ -2588,7 +3552,7 @@ mod fresh_quick_start_leader_binding_tests {
2588
3552
  let hermetic = HermeticTestEnv::enter("runtime-seeded-shared-nonce");
2589
3553
  let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
2590
3554
  let _pane = hermetic.with_env("TMUX_PANE", "%1");
2591
- let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
3555
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
2592
3556
  let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
2593
3557
  let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
2594
3558
  let parent = hermetic.root().join("parent");
@@ -2599,12 +3563,7 @@ mod fresh_quick_start_leader_binding_tests {
2599
3563
  .with_tmux_endpoint("/tmp/tmux.sock")
2600
3564
  .with_pane_current_command("%1", "bash")
2601
3565
  .with_targets(vec![target_with_winner.clone()])
2602
- .with_target_snapshots(vec![
2603
- vec![target_without_nonce.clone()],
2604
- vec![target_with_winner.clone()],
2605
- vec![target_without_nonce],
2606
- vec![target_with_winner],
2607
- ]);
3566
+ .with_target_snapshots(vec![vec![target_without_nonce]]);
2608
3567
  let first_workspace = runtime_workspace(&hermetic, "child-a");
2609
3568
  let second_workspace = runtime_workspace(&hermetic, "child-b");
2610
3569
  let first_seed = seeded_runtime_owner(&first_workspace);
@@ -2615,13 +3574,19 @@ mod fresh_quick_start_leader_binding_tests {
2615
3574
  cas_conflict: false,
2616
3575
  applied_grant: None,
2617
3576
  registry_receipt: None,
3577
+ frozen_owner: None,
3578
+ frozen_receiver: None,
2618
3579
  nonce_writer: Some(&nonce_writer_winner),
2619
3580
  };
2620
- assert!(bind_fresh_quick_start_leader_with(
2621
- &first_workspace,
2622
- "fresh",
2623
- Some(&first_seed),
2624
- &mut first_ops
3581
+ assert!(crate::transport_factory::with_leader_endpoint_transport(
3582
+ "/tmp/tmux.sock",
3583
+ transport.clone(),
3584
+ || bind_fresh_quick_start_leader_with(
3585
+ &first_workspace,
3586
+ "fresh",
3587
+ Some(&first_seed),
3588
+ &mut first_ops
3589
+ )
2625
3590
  )
2626
3591
  .unwrap());
2627
3592
  let mut second_ops = RuntimeFreshQuickStartLeaderBindingOps {
@@ -2630,13 +3595,19 @@ mod fresh_quick_start_leader_binding_tests {
2630
3595
  cas_conflict: false,
2631
3596
  applied_grant: None,
2632
3597
  registry_receipt: None,
3598
+ frozen_owner: None,
3599
+ frozen_receiver: None,
2633
3600
  nonce_writer: Some(&nonce_writer_winner),
2634
3601
  };
2635
- assert!(bind_fresh_quick_start_leader_with(
2636
- &second_workspace,
2637
- "fresh",
2638
- Some(&second_seed),
2639
- &mut second_ops
3602
+ assert!(crate::transport_factory::with_leader_endpoint_transport(
3603
+ "/tmp/tmux.sock",
3604
+ transport.clone(),
3605
+ || bind_fresh_quick_start_leader_with(
3606
+ &second_workspace,
3607
+ "fresh",
3608
+ Some(&second_seed),
3609
+ &mut second_ops
3610
+ )
2640
3611
  )
2641
3612
  .unwrap());
2642
3613
  let first_receiver = crate::state::persist::load_runtime_state(&first_workspace).unwrap();
@@ -2786,7 +3757,7 @@ mod fresh_quick_start_leader_binding_tests {
2786
3757
  std::fs::create_dir_all(&parent).unwrap();
2787
3758
  let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
2788
3759
  let _pane = hermetic.with_env("TMUX_PANE", "%1");
2789
- let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
3760
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
2790
3761
  let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
2791
3762
  let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
2792
3763
  let seed = seeded_runtime_owner(&workspace);
@@ -2807,6 +3778,8 @@ mod fresh_quick_start_leader_binding_tests {
2807
3778
  cas_conflict: false,
2808
3779
  applied_grant: None,
2809
3780
  registry_receipt: None,
3781
+ frozen_owner: None,
3782
+ frozen_receiver: None,
2810
3783
  nonce_writer: Some(&nonce_writer_winner),
2811
3784
  },
2812
3785
  register_ok: false,
@@ -2858,7 +3831,7 @@ mod fresh_quick_start_leader_binding_tests {
2858
3831
  std::fs::create_dir_all(&parent).unwrap();
2859
3832
  let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
2860
3833
  let _pane = hermetic.with_env("TMUX_PANE", "%1");
2861
- let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
3834
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
2862
3835
  let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
2863
3836
  let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
2864
3837
  let seed = seeded_runtime_owner(&workspace);
@@ -2874,6 +3847,8 @@ mod fresh_quick_start_leader_binding_tests {
2874
3847
  cas_conflict: false,
2875
3848
  applied_grant: None,
2876
3849
  registry_receipt: None,
3850
+ frozen_owner: None,
3851
+ frozen_receiver: None,
2877
3852
  nonce_writer: None,
2878
3853
  },
2879
3854
  register_ok: true,
@@ -2917,7 +3892,7 @@ mod fresh_quick_start_leader_binding_tests {
2917
3892
  std::fs::create_dir_all(&parent).unwrap();
2918
3893
  let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
2919
3894
  let _pane = hermetic.with_env("TMUX_PANE", "%1");
2920
- let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
3895
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
2921
3896
  let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
2922
3897
  let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
2923
3898
  let seed = seeded_runtime_owner(&workspace);
@@ -2937,13 +3912,14 @@ mod fresh_quick_start_leader_binding_tests {
2937
3912
  cas_conflict: false,
2938
3913
  applied_grant: None,
2939
3914
  registry_receipt: None,
3915
+ frozen_owner: None,
3916
+ frozen_receiver: None,
2940
3917
  nonce_writer: None,
2941
3918
  };
2942
- assert!(!bind_fresh_quick_start_leader_with(
2943
- &workspace,
2944
- "fresh",
2945
- Some(&seed),
2946
- &mut ops
3919
+ assert!(!crate::transport_factory::with_leader_endpoint_transport(
3920
+ "/tmp/tmux.sock",
3921
+ transport.clone(),
3922
+ || bind_fresh_quick_start_leader_with(&workspace, "fresh", Some(&seed), &mut ops)
2947
3923
  )
2948
3924
  .unwrap());
2949
3925
  assert_eq!(ops.last_failure_reason, Some("receiver_live_channel_unavailable"));
@@ -2972,7 +3948,7 @@ mod fresh_quick_start_leader_binding_tests {
2972
3948
  std::fs::create_dir_all(&parent).unwrap();
2973
3949
  let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
2974
3950
  let _pane = hermetic.with_env("TMUX_PANE", "%1");
2975
- let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
3951
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
2976
3952
  let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
2977
3953
  let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
2978
3954
  let seed = seeded_runtime_owner(&workspace);
@@ -2988,6 +3964,8 @@ mod fresh_quick_start_leader_binding_tests {
2988
3964
  cas_conflict: false,
2989
3965
  applied_grant: None,
2990
3966
  registry_receipt: None,
3967
+ frozen_owner: None,
3968
+ frozen_receiver: None,
2991
3969
  nonce_writer: None,
2992
3970
  },
2993
3971
  register_ok: true,
@@ -3029,7 +4007,7 @@ mod fresh_quick_start_leader_binding_tests {
3029
4007
  std::fs::create_dir_all(&parent).unwrap();
3030
4008
  let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3031
4009
  let _pane = hermetic.with_env("TMUX_PANE", "%1");
3032
- let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
4010
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
3033
4011
  let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
3034
4012
  let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
3035
4013
  let seed = seeded_runtime_owner(&workspace);
@@ -3045,6 +4023,8 @@ mod fresh_quick_start_leader_binding_tests {
3045
4023
  cas_conflict: false,
3046
4024
  applied_grant: None,
3047
4025
  registry_receipt: None,
4026
+ frozen_owner: None,
4027
+ frozen_receiver: None,
3048
4028
  nonce_writer: None,
3049
4029
  },
3050
4030
  register_ok: true,
@@ -3110,7 +4090,7 @@ mod fresh_quick_start_leader_binding_tests {
3110
4090
  std::fs::create_dir_all(&parent).unwrap();
3111
4091
  let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3112
4092
  let _pane = hermetic.with_env("TMUX_PANE", "%1");
3113
- let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
4093
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
3114
4094
  let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
3115
4095
  let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
3116
4096
  let seed = seeded_runtime_owner(&workspace);
@@ -3126,6 +4106,8 @@ mod fresh_quick_start_leader_binding_tests {
3126
4106
  cas_conflict: false,
3127
4107
  applied_grant: None,
3128
4108
  registry_receipt: None,
4109
+ frozen_owner: None,
4110
+ frozen_receiver: None,
3129
4111
  nonce_writer: None,
3130
4112
  },
3131
4113
  register_ok: true,
@@ -3188,7 +4170,7 @@ mod fresh_quick_start_leader_binding_tests {
3188
4170
  std::fs::create_dir_all(&parent).unwrap();
3189
4171
  let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3190
4172
  let _pane = hermetic.with_env("TMUX_PANE", "%1");
3191
- let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
4173
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
3192
4174
  let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
3193
4175
  let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
3194
4176
  let seed = seeded_runtime_owner(&workspace);
@@ -3213,6 +4195,8 @@ mod fresh_quick_start_leader_binding_tests {
3213
4195
  cas_conflict: false,
3214
4196
  applied_grant: None,
3215
4197
  registry_receipt: None,
4198
+ frozen_owner: None,
4199
+ frozen_receiver: None,
3216
4200
  nonce_writer: None,
3217
4201
  };
3218
4202
  assert!(!bind_fresh_quick_start_leader_with(
@@ -3236,14 +4220,14 @@ mod fresh_quick_start_leader_binding_tests {
3236
4220
 
3237
4221
  #[test]
3238
4222
  #[serial_test::serial(env)]
3239
- fn first_nonce_writer_is_product_generated_and_consumer_live() {
4223
+ fn first_nonce_writer_is_product_generated_and_consumer_live_provider_independent() {
3240
4224
  let hermetic = HermeticTestEnv::enter("runtime-first-nonce-writer");
3241
4225
  let workspace = runtime_workspace(&hermetic, "fresh");
3242
4226
  let parent = hermetic.root().join("parent");
3243
4227
  std::fs::create_dir_all(&parent).unwrap();
3244
4228
  let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3245
4229
  let _pane = hermetic.with_env("TMUX_PANE", "%1");
3246
- let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
4230
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
3247
4231
  let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
3248
4232
  let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
3249
4233
  let seed = seeded_runtime_owner(&workspace);
@@ -3261,7 +4245,7 @@ mod fresh_quick_start_leader_binding_tests {
3261
4245
  &workspace,
3262
4246
  &mut state,
3263
4247
  Some(&PaneId::new("%1")),
3264
- crate::provider::Provider::Pi,
4248
+ crate::provider::Provider::Codex,
3265
4249
  &event_log,
3266
4250
  crate::leader::LeaseSource::QuickStart,
3267
4251
  true,
@@ -3297,7 +4281,7 @@ mod fresh_quick_start_leader_binding_tests {
3297
4281
  &workspace,
3298
4282
  &mut state,
3299
4283
  Some(&PaneId::new("%1")),
3300
- crate::provider::Provider::Pi,
4284
+ crate::provider::Provider::Codex,
3301
4285
  &crate::event_log::EventLog::new(&workspace),
3302
4286
  crate::leader::LeaseSource::QuickStart,
3303
4287
  true,
@@ -3325,7 +4309,7 @@ mod fresh_quick_start_leader_binding_tests {
3325
4309
  std::fs::create_dir_all(&parent).unwrap();
3326
4310
  let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3327
4311
  let _pane = hermetic.with_env("TMUX_PANE", "%1");
3328
- let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
4312
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
3329
4313
  let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
3330
4314
  let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
3331
4315
  let seed = seeded_runtime_owner(&workspace);
@@ -3361,6 +4345,8 @@ mod fresh_quick_start_leader_binding_tests {
3361
4345
  cas_conflict: false,
3362
4346
  applied_grant: None,
3363
4347
  registry_receipt: None,
4348
+ frozen_owner: None,
4349
+ frozen_receiver: None,
3364
4350
  nonce_writer: Some(&concurrent_writer),
3365
4351
  };
3366
4352
  assert!(!bind_fresh_quick_start_leader_with(
@@ -3401,32 +4387,6 @@ mod fresh_quick_start_leader_binding_tests {
3401
4387
  assert_eq!(winner_entry.channel["pane_id"], json!("%winner"));
3402
4388
  }
3403
4389
 
3404
- #[test]
3405
- #[serial_test::serial(env)]
3406
- fn runtime_invalid_caller_refuses_even_when_command_is_pi() {
3407
- let hermetic = HermeticTestEnv::enter("runtime-invalid-command-pi");
3408
- let workspace = runtime_workspace(&hermetic, "fresh");
3409
- let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3410
- let _pane = hermetic.with_env("TMUX_PANE", "%1");
3411
- let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
3412
- let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%9");
3413
- let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
3414
- let transport = OfflineTransport::new()
3415
- .with_tmux_endpoint("/tmp/tmux.sock")
3416
- .with_pane_current_command("%1", "pi");
3417
- let mut ops = RecordingRuntimeOps::new(&transport);
3418
- assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
3419
- assert_eq!(ops.attach_calls, 0);
3420
- assert_eq!(ops.attached_provider, None);
3421
- assert_eq!(
3422
- refusal_reason(&workspace),
3423
- Some((
3424
- "strict_provider".to_string(),
3425
- "provider_unresolved".to_string()
3426
- ))
3427
- );
3428
- }
3429
-
3430
4390
  #[test]
3431
4391
  #[serial_test::serial(env)]
3432
4392
  fn runtime_socket_and_explicit_conflicts_refuse_without_command_fallback() {
@@ -3444,9 +4404,11 @@ mod fresh_quick_start_leader_binding_tests {
3444
4404
  "/tmp/product.sock",
3445
4405
  ),
3446
4406
  (
4407
+ // The explicit leader provider intentionally conflicts with the
4408
+ // caller provider; worker_env must reject this before command fallback.
3447
4409
  "leader",
3448
4410
  "/tmp/tmux.sock",
3449
- Some("codex"),
4411
+ Some("claude"),
3450
4412
  "/tmp/tmux.sock",
3451
4413
  ),
3452
4414
  ] {
@@ -3454,13 +4416,13 @@ mod fresh_quick_start_leader_binding_tests {
3454
4416
  let workspace = runtime_workspace(&hermetic, tag);
3455
4417
  let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3456
4418
  let _pane = hermetic.with_env("TMUX_PANE", "%1");
3457
- let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
4419
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "codex");
3458
4420
  let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
3459
4421
  let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, endpoint);
3460
4422
  let _leader = leader.map(|value| hermetic.with_env("TEAM_AGENT_LEADER_PROVIDER", value));
3461
4423
  let transport = OfflineTransport::new()
3462
4424
  .with_tmux_endpoint(scoped)
3463
- .with_pane_current_command("%1", "pi");
4425
+ .with_pane_current_command("%1", "codex");
3464
4426
  let mut ops = RecordingRuntimeOps::new(&transport);
3465
4427
  assert!(
3466
4428
  !bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap(),
@@ -3470,11 +4432,216 @@ mod fresh_quick_start_leader_binding_tests {
3470
4432
  }
3471
4433
  }
3472
4434
 
4435
+
4436
+ #[test]
4437
+ fn invalid_pi_tool_category_is_rejected_before_runtime_persistence() {
4438
+ let root = std::env::temp_dir().join(format!(
4439
+ "ta-quick-start-pi-tools-{}-{}",
4440
+ std::process::id(),
4441
+ SEQUENCE.fetch_add(1, Ordering::Relaxed)
4442
+ ));
4443
+ let team = root.join(".team/current");
4444
+ std::fs::create_dir_all(team.join("agents")).unwrap();
4445
+ std::fs::write(
4446
+ team.join("TEAM.md"),
4447
+ "---\nname: pi-tools\nprovider: pi\n---\n\nPi team.\n",
4448
+ )
4449
+ .unwrap();
4450
+ std::fs::write(
4451
+ team.join("agents/worker.md"),
4452
+ "---\nname: worker\nrole: Worker\nprovider: pi\nauth_mode: subscription\ndangerously_skip_permissions: false\ntools:\n - mcp_team\n - fs_read\n - provider_builtin\n---\n\nWorker.\n",
4453
+ )
4454
+ .unwrap();
4455
+
4456
+ let transport = crate::transport::test_support::OfflineTransport::new();
4457
+ let mut discover = |_requested: &str| -> Result<Vec<String>, ()> { Err(()) };
4458
+ let error = quick_start_with_transport_in_workspace_with_display_pi_preflight(
4459
+ &root,
4460
+ &team,
4461
+ None,
4462
+ true,
4463
+ None,
4464
+ &transport,
4465
+ false,
4466
+ &mut discover,
4467
+ )
4468
+ .expect_err("invalid Pi category must fail before quick-start persistence");
4469
+ let error = error.to_string();
4470
+ assert!(error.contains("Pi does not support Team Agent tool category \"provider_builtin\""));
4471
+ assert!(error.contains("remove it from the role's tools"));
4472
+ assert!(
4473
+ !crate::state::persist::runtime_state_path(&root).exists(),
4474
+ "quick-start validation failure must not persist runtime state"
4475
+ );
4476
+ assert!(
4477
+ !root.join(".team/runtime").exists(),
4478
+ "quick-start validation failure must not create runtime artifacts"
4479
+ );
4480
+ let _ = std::fs::remove_dir_all(root);
4481
+ }
4482
+
4483
+ #[test]
4484
+ fn fresh_binding_persists_then_registers_then_requires_canonical_readback() {
4485
+ let workspace = workspace_with_provider("positive-pi", "pi");
4486
+ let mut ops = MockOps::with_provider("pi");
4487
+ assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
4488
+ .unwrap());
4489
+ assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
4490
+ assert_eq!(ops.attach_calls, 1);
4491
+ assert_eq!(ops.register_calls, 1);
4492
+ assert_eq!(ops.readback_calls, 1);
4493
+ let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
4494
+ assert_eq!(
4495
+ state
4496
+ .get("teams")
4497
+ .and_then(serde_json::Value::as_object)
4498
+ .and_then(|teams| teams.get("fresh"))
4499
+ .and_then(|team| team.get("leader_receiver"))
4500
+ .and_then(|receiver| receiver.get("pane_id"))
4501
+ .and_then(serde_json::Value::as_str),
4502
+ Some("%42")
4503
+ );
4504
+ assert!(
4505
+ crate::event_log::EventLog::new(&workspace)
4506
+ .tail(0)
4507
+ .unwrap()
4508
+ .is_empty(),
4509
+ "successful bind must not emit a refusal event"
4510
+ );
4511
+ }
4512
+
4513
+ #[test]
4514
+ #[serial_test::serial(env)]
4515
+ fn runtime_valid_caller_binds_typed_pi_through_bash() {
4516
+ let hermetic = HermeticTestEnv::enter("runtime-valid-pi-bash");
4517
+ let workspace = runtime_workspace_with_provider(&hermetic, "fresh", "pi");
4518
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
4519
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
4520
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
4521
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
4522
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
4523
+ let transport = OfflineTransport::new()
4524
+ .with_tmux_endpoint("/tmp/tmux.sock")
4525
+ .with_pane_current_command("%1", "bash");
4526
+ let mut ops = RecordingRuntimeOps::new(&transport);
4527
+ assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
4528
+ assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
4529
+ assert_eq!(ops.attach_calls, 1);
4530
+ }
4531
+
4532
+ #[test]
4533
+ #[serial_test::serial(env)]
4534
+ fn first_nonce_writer_is_product_generated_and_consumer_live() {
4535
+ let hermetic = HermeticTestEnv::enter("runtime-first-nonce-writer");
4536
+ let workspace = runtime_workspace_with_provider(&hermetic, "fresh", "pi");
4537
+ let parent = hermetic.root().join("parent");
4538
+ std::fs::create_dir_all(&parent).unwrap();
4539
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
4540
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
4541
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
4542
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
4543
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
4544
+ let seed = seeded_runtime_owner(&workspace);
4545
+ let mut state = crate::state::projection::resolve_runtime_team_scope(
4546
+ &workspace,
4547
+ Some("fresh"),
4548
+ )
4549
+ .unwrap()
4550
+ .state;
4551
+ let expected_owner = state["team_owner"].clone();
4552
+ let expected_receiver = state["leader_receiver"].clone();
4553
+ let target = caller_target(parent.clone(), None);
4554
+ let event_log = crate::event_log::EventLog::new(&workspace);
4555
+ let (receiver, _) = crate::leader::attach_leader_to_state_with_target_and_controls(
4556
+ &workspace,
4557
+ &mut state,
4558
+ Some(&PaneId::new("%1")),
4559
+ crate::provider::Provider::Pi,
4560
+ &event_log,
4561
+ crate::leader::LeaseSource::QuickStart,
4562
+ true,
4563
+ Some(&target),
4564
+ Some(&expected_owner),
4565
+ Some(&expected_receiver),
4566
+ Some(&nonce_writer_winner),
4567
+ )
4568
+ .unwrap();
4569
+ let receiver = serde_json::to_value(receiver).unwrap();
4570
+ assert_eq!(receiver["binding_nonce"], json!("writer-winner"));
4571
+ let observed = caller_target(parent, Some("writer-winner"));
4572
+ let transport = OfflineTransport::new()
4573
+ .with_tmux_endpoint("/tmp/tmux.sock")
4574
+ .with_targets(vec![observed]);
4575
+ assert!(matches!(
4576
+ crate::messaging::resolve_live_leader_channel(&workspace, &receiver, &transport),
4577
+ crate::messaging::LeaderChannelResolution::Live(_)
4578
+ ));
4579
+
4580
+ let workspace = runtime_workspace_with_provider(&hermetic, "writer-failure", "pi");
4581
+ let seed = seeded_runtime_owner(&workspace);
4582
+ let mut state = crate::state::projection::resolve_runtime_team_scope(
4583
+ &workspace,
4584
+ Some("fresh"),
4585
+ )
4586
+ .unwrap()
4587
+ .state;
4588
+ let expected_owner = state["team_owner"].clone();
4589
+ let expected_receiver = state["leader_receiver"].clone();
4590
+ let target = caller_target(hermetic.root().join("parent"), None);
4591
+ let result = crate::leader::attach_leader_to_state_with_target_and_controls(
4592
+ &workspace,
4593
+ &mut state,
4594
+ Some(&PaneId::new("%1")),
4595
+ crate::provider::Provider::Pi,
4596
+ &crate::event_log::EventLog::new(&workspace),
4597
+ crate::leader::LeaseSource::QuickStart,
4598
+ true,
4599
+ Some(&target),
4600
+ Some(&expected_owner),
4601
+ Some(&expected_receiver),
4602
+ Some(&nonce_writer_failure),
4603
+ );
4604
+ assert!(result.is_err());
4605
+ let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
4606
+ assert_eq!(
4607
+ persisted.pointer("/teams/fresh/leader_receiver/binding_nonce"),
4608
+ None
4609
+ );
4610
+ assert!(!persisted.to_string().contains("fresh_caller"));
4611
+ let _ = seed;
4612
+ }
4613
+
4614
+ #[test]
4615
+ #[serial_test::serial(env)]
4616
+ fn runtime_invalid_caller_refuses_even_when_command_is_pi() {
4617
+ let hermetic = HermeticTestEnv::enter("runtime-invalid-command-pi");
4618
+ let workspace = runtime_workspace_with_provider(&hermetic, "fresh", "pi");
4619
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
4620
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
4621
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
4622
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%9");
4623
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
4624
+ let transport = OfflineTransport::new()
4625
+ .with_tmux_endpoint("/tmp/tmux.sock")
4626
+ .with_pane_current_command("%1", "pi");
4627
+ let mut ops = RecordingRuntimeOps::new(&transport);
4628
+ assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
4629
+ assert_eq!(ops.attach_calls, 0);
4630
+ assert_eq!(ops.attached_provider, None);
4631
+ assert_eq!(
4632
+ refusal_reason(&workspace),
4633
+ Some((
4634
+ "strict_provider".to_string(),
4635
+ "invalid_caller_tuple".to_string()
4636
+ ))
4637
+ );
4638
+ }
4639
+
3473
4640
  #[test]
3474
4641
  #[serial_test::serial(env)]
3475
4642
  fn runtime_absent_keeps_explicit_and_direct_paths() {
3476
4643
  let hermetic = HermeticTestEnv::enter("runtime-absent-compat");
3477
- let workspace = runtime_workspace(&hermetic, "explicit");
4644
+ let workspace = runtime_workspace_with_provider(&hermetic, "explicit", "pi");
3478
4645
  let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3479
4646
  let _pane = hermetic.with_env("TMUX_PANE", "%1");
3480
4647
  let _leader = hermetic.with_env("TEAM_AGENT_LEADER_PROVIDER", "pi");
@@ -3485,7 +4652,7 @@ mod fresh_quick_start_leader_binding_tests {
3485
4652
  assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
3486
4653
  assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
3487
4654
 
3488
- let workspace = runtime_workspace(&hermetic, "direct");
4655
+ let workspace = runtime_workspace_with_provider(&hermetic, "direct", "pi");
3489
4656
  drop(_leader);
3490
4657
  let transport = OfflineTransport::new()
3491
4658
  .with_tmux_endpoint("/tmp/tmux.sock")
@@ -3494,7 +4661,7 @@ mod fresh_quick_start_leader_binding_tests {
3494
4661
  assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
3495
4662
  assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
3496
4663
 
3497
- let workspace = runtime_workspace(&hermetic, "unknown-shell");
4664
+ let workspace = runtime_workspace_with_provider(&hermetic, "unknown-shell", "pi");
3498
4665
  let transport = OfflineTransport::new()
3499
4666
  .with_tmux_endpoint("/tmp/tmux.sock")
3500
4667
  .with_pane_current_command("%1", "bash");