@team-agent/installer 0.5.74 → 0.5.77

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 (45) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +9 -0
  4. package/README.zh.md +8 -0
  5. package/crates/team-agent/src/cli/adapters.rs +23 -96
  6. package/crates/team-agent/src/cli/emit.rs +35 -7
  7. package/crates/team-agent/src/cli/mod.rs +63 -42
  8. package/crates/team-agent/src/cli/models.rs +138 -27
  9. package/crates/team-agent/src/cli/status_port/brief.rs +1137 -0
  10. package/crates/team-agent/src/cli/status_port.rs +8 -0
  11. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  12. package/crates/team-agent/src/cli/tests/run_delegation.rs +29 -24
  13. package/crates/team-agent/src/cli/tests/status_brief.rs +691 -0
  14. package/crates/team-agent/src/cli/tests/status_send.rs +8 -2
  15. package/crates/team-agent/src/layout/worker_env.rs +366 -0
  16. package/crates/team-agent/src/leader/lease.rs +274 -40
  17. package/crates/team-agent/src/leader/registry.rs +177 -19
  18. package/crates/team-agent/src/leader/types.rs +4 -0
  19. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +93 -5
  20. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1820 -60
  21. package/crates/team-agent/src/lifecycle/launch/spawn.rs +4 -0
  22. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +138 -13
  23. package/crates/team-agent/src/lifecycle/restart/common.rs +3 -0
  24. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +11 -4
  25. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +45 -14
  26. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +1 -0
  27. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
  28. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +24 -28
  29. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +22 -10
  30. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +110 -31
  31. package/crates/team-agent/src/lifecycle/types.rs +4 -0
  32. package/crates/team-agent/src/messaging/delivery.rs +200 -29
  33. package/crates/team-agent/src/messaging/leader_channel.rs +12 -3
  34. package/crates/team-agent/src/messaging/watchers.rs +48 -4
  35. package/crates/team-agent/src/state/paths.rs +17 -2
  36. package/crates/team-agent/src/state/persist.rs +166 -6
  37. package/crates/team-agent/src/state/projection.rs +18 -2
  38. package/crates/team-agent/src/state/repository.rs +36 -0
  39. package/crates/team-agent/src/state/selector.rs +23 -3
  40. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +122 -0
  41. package/crates/team-agent/src/tmux_backend/tests.rs +350 -7
  42. package/crates/team-agent/src/tmux_backend.rs +217 -26
  43. package/crates/team-agent/src/transport/test_support.rs +18 -3
  44. package/crates/team-agent/src/transport.rs +99 -0
  45. package/package.json +4 -4
@@ -20,6 +20,10 @@
20
20
  //! - 团队嵌套超过两层直接拒绝
21
21
  //! maturity: wired
22
22
  //! ---
23
+ #[cfg(test)]
24
+ #[path = "../../../tests/support/hermetic.rs"]
25
+ mod hermetic;
26
+
23
27
  use std::collections::{BTreeMap, BTreeSet};
24
28
  use std::path::{Path, PathBuf};
25
29
  use std::process::Command;
@@ -38,6 +42,9 @@ use super::*;
38
42
 
39
43
  trait FreshQuickStartLeaderBindingOps {
40
44
  fn caller_pane(&mut self) -> Option<String>;
45
+ fn caller_resolution(&mut self) -> crate::layout::worker_env::CallerProviderResolution {
46
+ crate::layout::worker_env::CallerProviderResolution::Absent
47
+ }
41
48
  fn explicit_provider(&mut self) -> Option<String>;
42
49
  fn tmux_endpoint(&mut self) -> Option<String>;
43
50
  fn observe_command(&mut self, pane: &PaneId) -> Option<String>;
@@ -48,12 +55,36 @@ trait FreshQuickStartLeaderBindingOps {
48
55
  pane: &PaneId,
49
56
  provider: crate::provider::Provider,
50
57
  ) -> bool;
58
+ fn attach_failure_reason(&self) -> Option<&'static str> {
59
+ None
60
+ }
61
+ fn attach_cas_conflict(&self) -> bool {
62
+ false
63
+ }
64
+ fn applied_grant(&self) -> Option<(serde_json::Value, serde_json::Value)> {
65
+ None
66
+ }
67
+ fn registry_receipt(&self) -> Option<crate::leader::registry::RegistryWriteReceipt> {
68
+ None
69
+ }
51
70
  fn register(&mut self, workspace: &Path, team_key: &str) -> bool;
71
+ fn register_failure_reason(&self) -> Option<&'static str> {
72
+ None
73
+ }
52
74
  fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool;
75
+ fn readback_failure_reason(&self) -> Option<&'static str> {
76
+ None
77
+ }
53
78
  }
54
79
 
55
80
  struct RuntimeFreshQuickStartLeaderBindingOps<'a> {
56
81
  transport: &'a dyn Transport,
82
+ last_failure_reason: Option<&'static str>,
83
+ cas_conflict: bool,
84
+ applied_grant: Option<(serde_json::Value, serde_json::Value)>,
85
+ registry_receipt: Option<crate::leader::registry::RegistryWriteReceipt>,
86
+ nonce_writer:
87
+ Option<&'a dyn Fn(&str, &PaneId, &str) -> Result<String, crate::leader::LeaderError>>,
57
88
  }
58
89
 
59
90
  impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<'_> {
@@ -63,6 +94,18 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
63
94
  .filter(|pane| !pane.is_empty())
64
95
  }
65
96
 
97
+ fn caller_resolution(&mut self) -> crate::layout::worker_env::CallerProviderResolution {
98
+ let current_pane = std::env::var("TMUX_PANE")
99
+ .ok()
100
+ .filter(|pane| !pane.is_empty());
101
+ let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
102
+ crate::layout::worker_env::caller_provider_resolution(
103
+ current_pane.as_deref(),
104
+ current_endpoint.as_deref(),
105
+ self.transport.tmux_endpoint().as_deref(),
106
+ )
107
+ }
108
+
66
109
  fn explicit_provider(&mut self) -> Option<String> {
67
110
  std::env::var("TEAM_AGENT_LEADER_PROVIDER")
68
111
  .ok()
@@ -88,8 +131,36 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
88
131
  pane: &PaneId,
89
132
  provider: crate::provider::Provider,
90
133
  ) -> bool {
134
+ self.cas_conflict = false;
135
+ self.applied_grant = None;
136
+ self.registry_receipt = None;
91
137
  let event_log = crate::event_log::EventLog::new(workspace);
92
- crate::leader::attach_leader_to_state(
138
+ let targets = match self.transport.list_targets() {
139
+ Ok(targets) => targets,
140
+ Err(_) => {
141
+ self.last_failure_reason = Some("caller_pane_unobservable");
142
+ return false;
143
+ }
144
+ };
145
+ let Some(caller_target) = targets
146
+ .into_iter()
147
+ .find(|target| target.pane_id.as_str() == pane.as_str())
148
+ else {
149
+ self.last_failure_reason = Some("caller_pane_not_live");
150
+ return false;
151
+ };
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())
157
+ {
158
+ self.last_failure_reason = Some("caller_cwd_unobservable");
159
+ return false;
160
+ }
161
+ let expected_owner = state.get("team_owner").cloned();
162
+ let expected_receiver = state.get("leader_receiver").cloned();
163
+ match crate::leader::attach_leader_to_state_with_target_and_controls(
93
164
  workspace,
94
165
  state,
95
166
  Some(pane),
@@ -97,24 +168,159 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
97
168
  &event_log,
98
169
  crate::leader::LeaseSource::QuickStart,
99
170
  true,
100
- )
101
- .is_ok()
171
+ Some(&caller_target),
172
+ expected_owner.as_ref(),
173
+ expected_receiver.as_ref(),
174
+ self.nonce_writer,
175
+ ) {
176
+ Ok((receiver, _)) => {
177
+ let owner = state.get("team_owner").cloned();
178
+ let receiver = serde_json::to_value(receiver).ok();
179
+ self.applied_grant = owner.zip(receiver);
180
+ self.last_failure_reason = None;
181
+ true
182
+ }
183
+ Err(error) => {
184
+ let (error, applied_grant) = error.into_parts();
185
+ self.applied_grant = applied_grant.map(|grant| (grant.owner, grant.receiver));
186
+ self.cas_conflict = match &error {
187
+ crate::leader::LeaderError::State(crate::state::StateError::SaveConflict(_)) => true,
188
+ _ => false,
189
+ };
190
+ self.last_failure_reason = Some(if self.cas_conflict {
191
+ "attach_cas_conflict"
192
+ } else {
193
+ match &error {
194
+ crate::leader::LeaderError::Validation(_) => "attach_validation_failed",
195
+ crate::leader::LeaderError::Tmux(_) => "attach_transport_failed",
196
+ crate::leader::LeaderError::State(_) => "attach_state_failed",
197
+ crate::leader::LeaderError::Identity(_) => "attach_identity_failed",
198
+ crate::leader::LeaderError::Io(_)
199
+ | crate::leader::LeaderError::Json(_)
200
+ | crate::leader::LeaderError::MessageStore(_)
201
+ | crate::leader::LeaderError::Start(_) => "attach_failed",
202
+ crate::leader::LeaderError::EventLog(_) => "attach_event_log_failed",
203
+ crate::leader::LeaderError::Messaging(_) => "attach_watcher_failed",
204
+ }
205
+ });
206
+ false
207
+ }
208
+ }
209
+ }
210
+
211
+ fn attach_failure_reason(&self) -> Option<&'static str> {
212
+ self.last_failure_reason
213
+ }
214
+
215
+ fn attach_cas_conflict(&self) -> bool {
216
+ self.cas_conflict
217
+ }
218
+
219
+ fn applied_grant(&self) -> Option<(serde_json::Value, serde_json::Value)> {
220
+ self.applied_grant.clone()
221
+ }
222
+
223
+ fn registry_receipt(&self) -> Option<crate::leader::registry::RegistryWriteReceipt> {
224
+ self.registry_receipt.clone()
102
225
  }
103
226
 
104
227
  fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
105
- crate::leader::registry::register_binding_from_state_best_effort(
228
+ match crate::leader::registry::register_binding_from_state_best_effort(
106
229
  workspace,
107
230
  Some(team_key),
108
231
  "quick-start",
109
- )
110
- .is_some_and(|outcome| outcome.status == "registered" && outcome.path.is_some())
232
+ ) {
233
+ Some(outcome) if outcome.status == "registered" && outcome.path.is_some() => {
234
+ self.registry_receipt = outcome.receipt;
235
+ self.last_failure_reason = None;
236
+ true
237
+ }
238
+ Some(outcome) => {
239
+ self.last_failure_reason = Some(if outcome.status == "write_failed" {
240
+ "registry_write_failed"
241
+ } else {
242
+ "registry_registration_failed"
243
+ });
244
+ false
245
+ }
246
+ None => {
247
+ self.last_failure_reason = Some("registry_state_unavailable");
248
+ false
249
+ }
250
+ }
251
+ }
252
+
253
+ fn register_failure_reason(&self) -> Option<&'static str> {
254
+ self.last_failure_reason
111
255
  }
112
256
 
113
257
  fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
114
- launched_team_receiver_is_attached(workspace, team_key)
258
+ if !launched_team_receiver_is_attached(workspace, team_key) {
259
+ self.last_failure_reason = Some("registry_readback_unavailable");
260
+ return false;
261
+ }
262
+ let Ok(resolved) = crate::state::projection::resolve_runtime_team_scope(
263
+ workspace,
264
+ Some(team_key),
265
+ ) else {
266
+ self.last_failure_reason = Some("receiver_scope_unavailable");
267
+ return false;
268
+ };
269
+ let Some(receiver) = resolved.state.get("leader_receiver") else {
270
+ self.last_failure_reason = Some("receiver_missing");
271
+ return false;
272
+ };
273
+ if !matches!(
274
+ crate::messaging::resolve_live_leader_channel(
275
+ workspace,
276
+ receiver,
277
+ self.transport,
278
+ ),
279
+ crate::messaging::LeaderChannelResolution::Live(_)
280
+ ) {
281
+ self.last_failure_reason = Some("receiver_live_channel_unavailable");
282
+ return false;
283
+ }
284
+ self.last_failure_reason = None;
285
+ true
286
+ }
287
+
288
+ fn readback_failure_reason(&self) -> Option<&'static str> {
289
+ self.last_failure_reason
115
290
  }
116
291
  }
117
292
 
293
+ const FRESH_BIND_REFUSAL_EVENT: &str = "quick_start.leader_bind_refused";
294
+
295
+ // The event log is already scoped to `workspace`; `team_key` keeps a
296
+ // multi-team log unambiguous without copying paths or caller details.
297
+ fn record_fresh_bind_refusal(
298
+ workspace: &Path,
299
+ team_key: &str,
300
+ stage: &'static str,
301
+ reason: &'static str,
302
+ ) {
303
+ let _ = crate::event_log::EventLog::new(workspace).write(
304
+ FRESH_BIND_REFUSAL_EVENT,
305
+ serde_json::json!({
306
+ "operation": "fresh_quick_start_leader_bind",
307
+ "team_key": team_key,
308
+ "stage": stage,
309
+ "reason": reason,
310
+ }),
311
+ );
312
+ }
313
+
314
+ fn refuse_fresh_bind(
315
+ workspace: &Path,
316
+ team_key: &str,
317
+ stage: &'static str,
318
+ reason: &'static str,
319
+ ) -> Result<bool, LifecycleError> {
320
+ record_fresh_bind_refusal(workspace, team_key, stage, reason);
321
+ Ok(false)
322
+ }
323
+
118
324
  struct BindingFileSnapshot {
119
325
  path: PathBuf,
120
326
  bytes: Option<Vec<u8>>,
@@ -144,6 +350,7 @@ impl BindingFileSnapshot {
144
350
  }
145
351
  }
146
352
  }
353
+
147
354
  }
148
355
 
149
356
  fn binding_snapshots(workspace: &Path, team_key: &str) -> Vec<BindingFileSnapshot> {
@@ -240,28 +447,53 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
240
447
  workspace,
241
448
  Some(team_key),
242
449
  ) else {
243
- return Ok(false);
450
+ return refuse_fresh_bind(workspace, team_key, "scope", "scope_unresolved");
244
451
  };
245
452
  if resolved.canonical_team_key != team_key {
246
- return Ok(false);
453
+ return refuse_fresh_bind(workspace, team_key, "scope", "scope_key_mismatch");
247
454
  }
248
455
  let mut state = resolved.state;
249
456
  let Some(pane) = ops.caller_pane().filter(|pane| !pane.is_empty()) else {
250
- return Ok(false);
457
+ return refuse_fresh_bind(workspace, team_key, "caller_pane", "caller_pane_missing");
251
458
  };
252
459
  let pane = PaneId::new(pane);
253
460
  let Some(command) = ops
254
461
  .observe_command(&pane)
255
462
  .filter(|command| !command.trim().is_empty())
256
463
  else {
257
- return Ok(false);
464
+ return refuse_fresh_bind(
465
+ workspace,
466
+ team_key,
467
+ "command_observation",
468
+ "command_unobservable",
469
+ );
258
470
  };
259
- let explicit_provider = ops.explicit_provider();
260
- let Some(provider) = crate::leader::owner_bind::strict_owner_bind_provider(
261
- explicit_provider.as_deref(),
262
- &command,
263
- ) else {
264
- return Ok(false);
471
+ let provider = match ops.caller_resolution() {
472
+ crate::layout::worker_env::CallerProviderResolution::Valid(provider) => provider,
473
+ crate::layout::worker_env::CallerProviderResolution::Invalid => {
474
+ return refuse_fresh_bind(
475
+ workspace,
476
+ team_key,
477
+ "strict_provider",
478
+ "provider_unresolved",
479
+ );
480
+ }
481
+ crate::layout::worker_env::CallerProviderResolution::Absent => {
482
+ match crate::leader::owner_bind::strict_owner_bind_provider(
483
+ ops.explicit_provider().as_deref(),
484
+ &command,
485
+ ) {
486
+ Some(provider) => provider,
487
+ None => {
488
+ return refuse_fresh_bind(
489
+ workspace,
490
+ team_key,
491
+ "strict_provider",
492
+ "provider_unresolved",
493
+ );
494
+ }
495
+ }
496
+ }
265
497
  };
266
498
  let endpoint = ops.tmux_endpoint();
267
499
  let has_persisted_binding = ["team_owner", "leader_receiver"]
@@ -276,6 +508,11 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
276
508
  .get("team_owner")
277
509
  .is_some_and(|current| current == seed)
278
510
  });
511
+ let seeded_receiver = if fresh_seeded_binding {
512
+ state.get("leader_receiver").cloned()
513
+ } else {
514
+ None
515
+ };
279
516
  // Same-Team owner collision remains fail-closed. Cross-Team registry rows
280
517
  // are intentionally irrelevant: pane ids are scoped to their tmux server,
281
518
  // and independent endpoints may reuse the same numeric id.
@@ -288,40 +525,125 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
288
525
  endpoint.as_deref(),
289
526
  )
290
527
  {
291
- return Ok(false);
528
+ return refuse_fresh_bind(
529
+ workspace,
530
+ team_key,
531
+ "persisted_binding",
532
+ "persisted_binding_mismatch",
533
+ );
292
534
  }
293
535
  // Registry publication is the commit point. Restore persisted surfaces on
294
536
  // failure, except for the caller-seeded owner that must be cleared.
295
537
  let snapshots = binding_snapshots(workspace, team_key);
296
- let attached = has_persisted_binding || ops.attach(workspace, &mut state, &pane, provider);
297
- let committed = attached
298
- && ops.register(workspace, team_key)
299
- && ops.canonical_readback(workspace, team_key);
538
+ let attached = if fresh_seeded_binding {
539
+ ops.attach(workspace, &mut state, &pane, provider)
540
+ } else {
541
+ has_persisted_binding || ops.attach(workspace, &mut state, &pane, provider)
542
+ };
543
+ let cleanup_grant = ops.applied_grant().or_else(|| {
544
+ seeded_owner
545
+ .filter(|seed| {
546
+ fresh_seeded_binding
547
+ && state
548
+ .get("team_owner")
549
+ .is_some_and(|current| current == *seed)
550
+ })
551
+ .zip(seeded_receiver.clone())
552
+ .map(|(owner, receiver)| (owner.clone(), receiver))
553
+ });
554
+ let committed = if !attached {
555
+ record_fresh_bind_refusal(
556
+ workspace,
557
+ team_key,
558
+ "attach",
559
+ ops.attach_failure_reason().unwrap_or("attach_failed"),
560
+ );
561
+ false
562
+ } else if !ops.register(workspace, team_key) {
563
+ record_fresh_bind_refusal(
564
+ workspace,
565
+ team_key,
566
+ "registry_register",
567
+ ops.register_failure_reason()
568
+ .unwrap_or("registry_register_failed"),
569
+ );
570
+ false
571
+ } else if !ops.canonical_readback(workspace, team_key) {
572
+ record_fresh_bind_refusal(
573
+ workspace,
574
+ team_key,
575
+ "registry_readback",
576
+ ops.readback_failure_reason()
577
+ .unwrap_or("registry_readback_failed"),
578
+ );
579
+ false
580
+ } else {
581
+ true
582
+ };
300
583
  if !committed {
301
- if let Some(seed) = seeded_owner.filter(|seed| {
302
- fresh_seeded_binding
303
- && state
584
+ if ops.attach_cas_conflict() {
585
+ return Ok(false);
586
+ }
587
+ if fresh_seeded_binding {
588
+ // The registry receipt is the exact bytes this writer published;
589
+ // conditional restore compares and mutates under the registry
590
+ // owner's lock, so a concurrent winner is never rolled back.
591
+ let registry_rollback_error = ops
592
+ .registry_receipt()
593
+ .and_then(|receipt| restore_registry_receipt(&snapshots, &receipt).err());
594
+ if let Some((expected_owner, expected_receiver)) = cleanup_grant.as_ref() {
595
+ clear_fresh_binding_on_refusal(
596
+ workspace,
597
+ &mut state,
598
+ team_key,
599
+ expected_owner,
600
+ Some((expected_owner, expected_receiver)),
601
+ )?;
602
+ } else if let Some(seed) = seeded_owner.filter(|seed| {
603
+ state
304
604
  .get("team_owner")
305
605
  .is_some_and(|current| current == *seed)
306
- }) {
307
- // Restore only the registry surface. The initial runtime state may
308
- // already contain a caller-seeded owner; restoring it after a failed
309
- // commit would make a later claim falsely report already_bound.
310
- for snapshot in snapshots.iter().skip(1).rev() {
311
- snapshot.restore();
606
+ }) {
607
+ clear_fresh_binding_on_refusal(workspace, &mut state, team_key, seed, None)?;
608
+ }
609
+ if let Some(error) = registry_rollback_error {
610
+ return Err(error);
312
611
  }
313
- clear_fresh_binding_on_refusal(workspace, &mut state, team_key, seed)?;
314
612
  } else {
315
- // No caller-seeded owner was ours to clean up, so restore all
316
- // surfaces byte-for-byte after an attach/register/readback failure.
317
- for snapshot in snapshots.iter().rev() {
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
+ let registry_rollback_error = ops
617
+ .registry_receipt()
618
+ .and_then(|receipt| restore_registry_receipt(&snapshots, &receipt).err());
619
+ if let Some(snapshot) = snapshots.first() {
318
620
  snapshot.restore();
319
621
  }
622
+ if let Some(error) = registry_rollback_error {
623
+ return Err(error);
624
+ }
320
625
  }
321
626
  }
322
627
  Ok(committed)
323
628
  }
324
629
 
630
+ fn restore_registry_receipt(
631
+ snapshots: &[BindingFileSnapshot],
632
+ receipt: &crate::leader::registry::RegistryWriteReceipt,
633
+ ) -> Result<(), LifecycleError> {
634
+ let previous = snapshots
635
+ .iter()
636
+ .find(|snapshot| snapshot.path == receipt.path)
637
+ .and_then(|snapshot| snapshot.bytes.as_deref());
638
+ match crate::leader::registry::restore_entry_if_current_matches(receipt, previous) {
639
+ Ok(crate::leader::registry::RegistryRollback::Restored)
640
+ | Ok(crate::leader::registry::RegistryRollback::Superseded) => Ok(()),
641
+ Err(error) => Err(LifecycleError::StatePersist(format!(
642
+ "quick-start registry rollback failed: {error}"
643
+ ))),
644
+ }
645
+ }
646
+
325
647
  fn should_emit_workspace_socket_missing_hint(
326
648
  selected_source: Option<&str>,
327
649
  workspace_socket_missing: bool,
@@ -334,10 +656,11 @@ fn clear_fresh_binding_on_refusal(
334
656
  state: &mut serde_json::Value,
335
657
  team_key: &str,
336
658
  seed: &serde_json::Value,
659
+ expected_grant: Option<(&serde_json::Value, &serde_json::Value)>,
337
660
  ) -> Result<(), LifecycleError> {
338
- // Tombstone only this attempt's seed. The lock-held persist merge compares
339
- // the on-disk owner to `seed`; a concurrent equal-epoch owner is copied
340
- // back and the epoch is not bumped.
661
+ // Tombstone only this attempt's exact grant. The lock-held persist merge
662
+ // compares the on-disk owner and applied receiver; a concurrent owner or
663
+ // receiver update is copied back and the epoch is not bumped.
341
664
  if let Some(obj) = state.as_object_mut() {
342
665
  obj.remove("leader_receiver");
343
666
  obj.remove("team_owner");
@@ -352,11 +675,17 @@ fn clear_fresh_binding_on_refusal(
352
675
  team.insert("leader_receiver".to_string(), serde_json::Value::Null);
353
676
  team.insert("team_owner".to_string(), serde_json::Value::Null);
354
677
  }
678
+ let intent = if let Some((expected_owner, expected_receiver)) = expected_grant {
679
+ crate::state::repository::StateWriteIntent::ClearExactTeamOwnerAndReceiver {
680
+ team_key,
681
+ seed: expected_owner,
682
+ expected_receiver,
683
+ }
684
+ } else {
685
+ crate::state::repository::StateWriteIntent::ClearExactTeamOwner { team_key, seed }
686
+ };
355
687
  crate::state::repository::StateRepository::new(workspace)
356
- .save(
357
- crate::state::repository::StateWriteIntent::ClearExactTeamOwner { team_key, seed },
358
- state,
359
- )
688
+ .save(intent, state)
360
689
  .map_err(|error| LifecycleError::StatePersist(format!(
361
690
  "quick-start binding cleanup failed: {error}"
362
691
  )))
@@ -372,7 +701,14 @@ fn bind_fresh_quick_start_leader(
372
701
  workspace,
373
702
  team_key,
374
703
  seeded_owner,
375
- &mut RuntimeFreshQuickStartLeaderBindingOps { transport },
704
+ &mut RuntimeFreshQuickStartLeaderBindingOps {
705
+ transport,
706
+ last_failure_reason: None,
707
+ cas_conflict: false,
708
+ applied_grant: None,
709
+ registry_receipt: None,
710
+ nonce_writer: None,
711
+ },
376
712
  )
377
713
  }
378
714
 
@@ -785,12 +1121,14 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
785
1121
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
786
1122
  let resolved_spec_path =
787
1123
  std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
1124
+ let scoped_endpoint = transport.tmux_endpoint();
788
1125
  let mut state = initial_runtime_state(
789
1126
  &spec,
790
1127
  &resolved_spec_path,
791
1128
  &workspace,
792
1129
  agents_dir,
793
1130
  &state_team_key,
1131
+ scoped_endpoint.as_deref(),
794
1132
  );
795
1133
  // Keep this attempt-local seed separate from persisted `claimed_via`;
796
1134
  // historical quick-start rows are never sufficient cleanup authority.
@@ -926,9 +1264,15 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
926
1264
  #[cfg(test)]
927
1265
  mod fresh_quick_start_leader_binding_tests {
928
1266
  use super::*;
1267
+ use crate::layout::worker_env::{CALLER_ENDPOINT_ENV, CALLER_PANE_ENV, CALLER_PROVIDER_ENV};
1268
+ use crate::transport::test_support::OfflineTransport;
1269
+ use crate::transport::{PaneInfo, SessionName, WindowName};
929
1270
  use serde_json::json;
1271
+ use std::collections::BTreeMap;
930
1272
  use std::sync::atomic::{AtomicU64, Ordering};
931
1273
 
1274
+ use super::hermetic::HermeticTestEnv;
1275
+
932
1276
  static SEQUENCE: AtomicU64 = AtomicU64::new(0);
933
1277
 
934
1278
  fn workspace(tag: &str) -> PathBuf {
@@ -963,6 +1307,7 @@ mod fresh_quick_start_leader_binding_tests {
963
1307
  register_calls: usize,
964
1308
  readback_calls: usize,
965
1309
  attached_provider: Option<crate::provider::Provider>,
1310
+ applied_grant: Option<(serde_json::Value, serde_json::Value)>,
966
1311
  }
967
1312
 
968
1313
  impl Default for MockOps {
@@ -979,6 +1324,7 @@ mod fresh_quick_start_leader_binding_tests {
979
1324
  register_calls: 0,
980
1325
  readback_calls: 0,
981
1326
  attached_provider: None,
1327
+ applied_grant: None,
982
1328
  }
983
1329
  }
984
1330
  }
@@ -1054,6 +1400,7 @@ mod fresh_quick_start_leader_binding_tests {
1054
1400
  pane: &PaneId,
1055
1401
  provider: crate::provider::Provider,
1056
1402
  ) -> bool {
1403
+ self.applied_grant = None;
1057
1404
  self.attach_calls += 1;
1058
1405
  self.attached_provider = Some(provider);
1059
1406
  if !self.attach_ok {
@@ -1061,30 +1408,79 @@ mod fresh_quick_start_leader_binding_tests {
1061
1408
  let _ = crate::state::persist::save_runtime_state(workspace, state);
1062
1409
  return false;
1063
1410
  }
1064
- state["leader_receiver"] = json!({
1411
+ let team_key = state
1412
+ .get("active_team_key")
1413
+ .and_then(serde_json::Value::as_str)
1414
+ .unwrap_or("fresh")
1415
+ .to_string();
1416
+ let expected_owner = state.get("team_owner").cloned();
1417
+ let expected_receiver = state.get("leader_receiver").cloned();
1418
+ let owner = expected_owner.clone().unwrap_or_else(|| {
1419
+ json!({
1420
+ "pane_id": pane.as_str(),
1421
+ "provider": "pi",
1422
+ "leader_session_uuid": "uuid-fresh",
1423
+ "owner_epoch": 1
1424
+ })
1425
+ });
1426
+ let owner_epoch = owner
1427
+ .get("owner_epoch")
1428
+ .and_then(serde_json::Value::as_u64)
1429
+ .unwrap_or(1);
1430
+ let receiver = json!({
1065
1431
  "mode": "direct_tmux",
1066
1432
  "pane_id": pane.as_str(),
1067
1433
  "status": "attached",
1068
1434
  "provider": "pi",
1069
1435
  "leader_session_uuid": "uuid-fresh",
1070
- "owner_epoch": 1,
1436
+ "owner_epoch": owner_epoch,
1071
1437
  "tmux_socket": self.endpoint
1072
1438
  });
1073
- state["team_owner"] = json!({
1074
- "pane_id": pane.as_str(),
1075
- "provider": "pi",
1076
- "leader_session_uuid": "uuid-fresh",
1077
- "owner_epoch": 1
1078
- });
1079
- crate::state::persist::save_runtime_state(workspace, state).is_ok()
1439
+ state["leader_receiver"] = receiver.clone();
1440
+ state["team_owner"] = owner.clone();
1441
+ state["owner_epoch"] = json!(owner_epoch);
1442
+ state["teams"][team_key.as_str()]["leader_receiver"] = receiver.clone();
1443
+ state["teams"][team_key.as_str()]["team_owner"] = owner.clone();
1444
+ state["teams"][team_key.as_str()]["owner_epoch"] = json!(owner_epoch);
1445
+ let saved = match (expected_owner.as_ref(), expected_receiver.as_ref()) {
1446
+ (Some(expected_owner), Some(expected_receiver)) => {
1447
+ crate::state::persist::save_runtime_state_with_receiver_authority_and_expected(
1448
+ workspace,
1449
+ state,
1450
+ &team_key,
1451
+ expected_owner,
1452
+ expected_receiver,
1453
+ )
1454
+ }
1455
+ _ => crate::state::persist::save_runtime_state_with_receiver_authority(
1456
+ workspace,
1457
+ state,
1458
+ &team_key,
1459
+ None,
1460
+ ),
1461
+ };
1462
+ if saved.is_ok() {
1463
+ self.applied_grant = Some((owner, receiver));
1464
+ true
1465
+ } else {
1466
+ false
1467
+ }
1468
+ }
1469
+
1470
+ fn applied_grant(&self) -> Option<(serde_json::Value, serde_json::Value)> {
1471
+ self.applied_grant.clone()
1080
1472
  }
1081
1473
 
1082
- fn register(&mut self, workspace: &Path, _team_key: &str) -> bool {
1474
+ fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
1083
1475
  self.register_calls += 1;
1084
1476
  let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
1085
1477
  assert_eq!(
1086
1478
  persisted
1087
- .pointer("/leader_receiver/pane_id")
1479
+ .get("teams")
1480
+ .and_then(serde_json::Value::as_object)
1481
+ .and_then(|teams| teams.get(team_key))
1482
+ .and_then(|team| team.get("leader_receiver"))
1483
+ .and_then(|receiver| receiver.get("pane_id"))
1088
1484
  .and_then(serde_json::Value::as_str),
1089
1485
  Some("%42")
1090
1486
  );
@@ -1095,7 +1491,7 @@ mod fresh_quick_start_leader_binding_tests {
1095
1491
  self.register_ok
1096
1492
  }
1097
1493
 
1098
- fn canonical_readback(&mut self, workspace: &Path, _team_key: &str) -> bool {
1494
+ fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
1099
1495
  self.readback_calls += 1;
1100
1496
  let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
1101
1497
  if !self.readback_ok {
@@ -1104,7 +1500,10 @@ mod fresh_quick_start_leader_binding_tests {
1104
1500
  }
1105
1501
  self.readback_ok
1106
1502
  && persisted
1107
- .get("leader_receiver")
1503
+ .get("teams")
1504
+ .and_then(serde_json::Value::as_object)
1505
+ .and_then(|teams| teams.get(team_key))
1506
+ .and_then(|team| team.get("leader_receiver"))
1108
1507
  .is_some_and(|receiver| !receiver.is_null())
1109
1508
  }
1110
1509
  }
@@ -1122,10 +1521,110 @@ mod fresh_quick_start_leader_binding_tests {
1122
1521
  let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
1123
1522
  assert_eq!(
1124
1523
  state
1125
- .pointer("/leader_receiver/pane_id")
1524
+ .get("teams")
1525
+ .and_then(serde_json::Value::as_object)
1526
+ .and_then(|teams| teams.get("fresh"))
1527
+ .and_then(|team| team.get("leader_receiver"))
1528
+ .and_then(|receiver| receiver.get("pane_id"))
1126
1529
  .and_then(serde_json::Value::as_str),
1127
1530
  Some("%42")
1128
1531
  );
1532
+ assert!(
1533
+ crate::event_log::EventLog::new(&workspace)
1534
+ .tail(0)
1535
+ .unwrap()
1536
+ .is_empty(),
1537
+ "successful bind must not emit a refusal event"
1538
+ );
1539
+ }
1540
+
1541
+ #[test]
1542
+ fn fresh_bind_refusal_event_keeps_first_safe_stage_and_reason() {
1543
+ for (case, expected_stage, expected_reason) in [
1544
+ ("missing_pane", "caller_pane", "caller_pane_missing"),
1545
+ ("empty_command", "command_observation", "command_unobservable"),
1546
+ ("unknown_provider", "strict_provider", "provider_unresolved"),
1547
+ (
1548
+ "existing_owner",
1549
+ "persisted_binding",
1550
+ "persisted_binding_mismatch",
1551
+ ),
1552
+ ("attach_failure", "attach", "attach_failed"),
1553
+ (
1554
+ "registry_failure",
1555
+ "registry_register",
1556
+ "registry_register_failed",
1557
+ ),
1558
+ (
1559
+ "readback_failure",
1560
+ "registry_readback",
1561
+ "registry_readback_failed",
1562
+ ),
1563
+ ] {
1564
+ let workspace = workspace(case);
1565
+ let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
1566
+ let mut ops = MockOps::default();
1567
+ match case {
1568
+ "missing_pane" => ops.pane = None,
1569
+ "empty_command" => ops.command = Some(" ".to_string()),
1570
+ "unknown_provider" => {
1571
+ ops.explicit_provider = None;
1572
+ ops.command = Some("node".to_string());
1573
+ }
1574
+ "existing_owner" => state["team_owner"] = json!({"pane_id": "%old"}),
1575
+ "attach_failure" => ops.attach_ok = false,
1576
+ "registry_failure" => ops.register_ok = false,
1577
+ "readback_failure" => ops.readback_ok = false,
1578
+ _ => unreachable!(),
1579
+ }
1580
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
1581
+
1582
+ assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
1583
+ .unwrap());
1584
+ let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
1585
+ assert_eq!(events.len(), 1, "{case} must emit one first-refusal event");
1586
+ assert_eq!(events[0]["event"], json!(FRESH_BIND_REFUSAL_EVENT));
1587
+ assert_eq!(events[0]["operation"], json!("fresh_quick_start_leader_bind"));
1588
+ assert_eq!(events[0]["team_key"], json!("fresh"));
1589
+ assert_eq!(events[0]["stage"], json!(expected_stage));
1590
+ assert_eq!(events[0]["reason"], json!(expected_reason));
1591
+ assert!(events[0].get("command").is_none());
1592
+ assert!(events[0].get("pane_id").is_none());
1593
+ }
1594
+ }
1595
+
1596
+ #[test]
1597
+ fn scope_refusal_event_is_safe_and_targeted() {
1598
+ let empty_workspace = std::env::temp_dir().join(format!(
1599
+ "ta-quick-start-bind-scope-{}-{}",
1600
+ std::process::id(),
1601
+ SEQUENCE.fetch_add(1, Ordering::Relaxed)
1602
+ ));
1603
+ std::fs::create_dir_all(&empty_workspace).unwrap();
1604
+ let mut ops = MockOps::default();
1605
+ assert!(!bind_fresh_quick_start_leader_with(&empty_workspace, "fresh", None, &mut ops)
1606
+ .unwrap());
1607
+ let events = crate::event_log::EventLog::new(&empty_workspace).tail(0).unwrap();
1608
+ assert_eq!(events.len(), 1);
1609
+ assert_eq!(events[0]["stage"], json!("scope"));
1610
+ assert_eq!(events[0]["reason"], json!("scope_unresolved"));
1611
+ assert_eq!(events[0]["team_key"], json!("fresh"));
1612
+ assert!(events[0].get("error").is_none());
1613
+ assert!(events[0].get("argv").is_none());
1614
+ let _ = std::fs::remove_dir_all(empty_workspace);
1615
+
1616
+ let workspace = workspace("scope-key-mismatch");
1617
+ let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
1618
+ state["active_team_key"] = json!("fresh");
1619
+ state["teams"] = json!({"fresh": {"team_key": "fresh", "agents": {}}});
1620
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
1621
+ let mut ops = MockOps::default();
1622
+ assert!(!bind_fresh_quick_start_leader_with(&workspace, "current", None, &mut ops)
1623
+ .unwrap());
1624
+ let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
1625
+ assert_eq!(events.len(), 1);
1626
+ assert_eq!(events[0]["stage"], json!("scope"));
1627
+ assert_eq!(events[0]["reason"], json!("scope_key_mismatch"));
1129
1628
  }
1130
1629
 
1131
1630
  #[test]
@@ -1279,6 +1778,7 @@ mod fresh_quick_start_leader_binding_tests {
1279
1778
  struct AmbientRegistryOps {
1280
1779
  readback_ok: bool,
1281
1780
  attach_calls: usize,
1781
+ registry_receipt: Option<crate::leader::registry::RegistryWriteReceipt>,
1282
1782
  }
1283
1783
 
1284
1784
  impl FreshQuickStartLeaderBindingOps for AmbientRegistryOps {
@@ -1310,12 +1810,19 @@ mod fresh_quick_start_leader_binding_tests {
1310
1810
  }
1311
1811
 
1312
1812
  fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
1313
- crate::leader::registry::register_binding_from_state_best_effort(
1813
+ let Some(outcome) = crate::leader::registry::register_binding_from_state_best_effort(
1314
1814
  workspace,
1315
1815
  Some(team_key),
1316
1816
  "quick-start",
1317
- )
1318
- .is_some_and(|outcome| outcome.status == "registered" && outcome.path.is_some())
1817
+ ) else {
1818
+ return false;
1819
+ };
1820
+ self.registry_receipt = outcome.receipt;
1821
+ outcome.status == "registered" && outcome.path.is_some()
1822
+ }
1823
+
1824
+ fn registry_receipt(&self) -> Option<crate::leader::registry::RegistryWriteReceipt> {
1825
+ self.registry_receipt.clone()
1319
1826
  }
1320
1827
 
1321
1828
  fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
@@ -1336,6 +1843,7 @@ mod fresh_quick_start_leader_binding_tests {
1336
1843
  let mut ops = AmbientRegistryOps {
1337
1844
  readback_ok: true,
1338
1845
  attach_calls: 0,
1846
+ registry_receipt: None,
1339
1847
  };
1340
1848
  assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
1341
1849
  .unwrap());
@@ -1500,6 +2008,10 @@ mod fresh_quick_start_leader_binding_tests {
1500
2008
  self.inner.attach(workspace, state, pane, provider)
1501
2009
  }
1502
2010
 
2011
+ fn applied_grant(&self) -> Option<(serde_json::Value, serde_json::Value)> {
2012
+ self.inner.applied_grant()
2013
+ }
2014
+
1503
2015
  fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
1504
2016
  self.inner.register_calls += 1;
1505
2017
  if let Some(takeover) = &self.takeover {
@@ -1575,6 +2087,15 @@ mod fresh_quick_start_leader_binding_tests {
1575
2087
  &mut ops
1576
2088
  )
1577
2089
  .unwrap());
2090
+ assert_eq!(ops.inner.attach_calls, 1);
2091
+ assert_eq!(ops.inner.register_calls, 1);
2092
+ assert!(ops
2093
+ .inner
2094
+ .applied_grant
2095
+ .as_ref()
2096
+ .is_some_and(|(owner, receiver)| {
2097
+ owner == &seed && receiver == &matching_receiver()
2098
+ }));
1578
2099
  let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
1579
2100
  assert!(
1580
2101
  persisted
@@ -1642,6 +2163,15 @@ mod fresh_quick_start_leader_binding_tests {
1642
2163
  &mut ops,
1643
2164
  )
1644
2165
  .expect_err("poisoned state.json must surface cleanup persist failure");
2166
+ assert_eq!(ops.inner.attach_calls, 1);
2167
+ assert_eq!(ops.inner.register_calls, 1);
2168
+ assert!(ops
2169
+ .inner
2170
+ .applied_grant
2171
+ .as_ref()
2172
+ .is_some_and(|(owner, receiver)| {
2173
+ owner == &seed && receiver == &matching_receiver()
2174
+ }));
1645
2175
  match error {
1646
2176
  LifecycleError::StatePersist(text) => {
1647
2177
  assert!(
@@ -1743,10 +2273,1240 @@ mod fresh_quick_start_leader_binding_tests {
1743
2273
  let mut ops = AmbientRegistryOps {
1744
2274
  readback_ok: false,
1745
2275
  attach_calls: 0,
2276
+ registry_receipt: None,
1746
2277
  };
1747
2278
  assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
1748
2279
  .unwrap());
1749
2280
  assert_eq!(std::fs::read(state_path).unwrap(), before);
1750
2281
  assert!(!registry_path.exists(), "failed readback left registry bytes");
1751
2282
  }
2283
+
2284
+ struct RecordingRuntimeOps<'a> {
2285
+ inner: RuntimeFreshQuickStartLeaderBindingOps<'a>,
2286
+ attached_provider: Option<crate::provider::Provider>,
2287
+ attach_calls: usize,
2288
+ }
2289
+
2290
+ impl<'a> RecordingRuntimeOps<'a> {
2291
+ fn new(transport: &'a dyn Transport) -> Self {
2292
+ Self {
2293
+ inner: RuntimeFreshQuickStartLeaderBindingOps {
2294
+ transport,
2295
+ last_failure_reason: None,
2296
+ cas_conflict: false,
2297
+ applied_grant: None,
2298
+ registry_receipt: None,
2299
+ nonce_writer: None,
2300
+ },
2301
+ attached_provider: None,
2302
+ attach_calls: 0,
2303
+ }
2304
+ }
2305
+ }
2306
+
2307
+ impl FreshQuickStartLeaderBindingOps for RecordingRuntimeOps<'_> {
2308
+ fn caller_pane(&mut self) -> Option<String> {
2309
+ self.inner.caller_pane()
2310
+ }
2311
+
2312
+ fn caller_resolution(&mut self) -> crate::layout::worker_env::CallerProviderResolution {
2313
+ self.inner.caller_resolution()
2314
+ }
2315
+
2316
+ fn explicit_provider(&mut self) -> Option<String> {
2317
+ self.inner.explicit_provider()
2318
+ }
2319
+
2320
+ fn tmux_endpoint(&mut self) -> Option<String> {
2321
+ self.inner.tmux_endpoint()
2322
+ }
2323
+
2324
+ fn observe_command(&mut self, pane: &PaneId) -> Option<String> {
2325
+ self.inner.observe_command(pane)
2326
+ }
2327
+
2328
+ fn attach(
2329
+ &mut self,
2330
+ workspace: &Path,
2331
+ state: &mut serde_json::Value,
2332
+ pane: &PaneId,
2333
+ provider: crate::provider::Provider,
2334
+ ) -> bool {
2335
+ self.attach_calls += 1;
2336
+ self.attached_provider = Some(provider);
2337
+ let wire = crate::provider::wire::provider_wire(provider);
2338
+ state["leader_receiver"] = json!({
2339
+ "mode": "direct_tmux",
2340
+ "pane_id": pane.as_str(),
2341
+ "status": "attached",
2342
+ "provider": wire,
2343
+ "leader_session_uuid": "uuid-fresh",
2344
+ "owner_epoch": 1,
2345
+ "tmux_socket": self.inner.tmux_endpoint()
2346
+ });
2347
+ state["team_owner"] = json!({
2348
+ "pane_id": pane.as_str(),
2349
+ "provider": wire,
2350
+ "leader_session_uuid": "uuid-fresh",
2351
+ "owner_epoch": 1
2352
+ });
2353
+ crate::state::persist::save_runtime_state(workspace, state).is_ok()
2354
+ }
2355
+
2356
+ fn register(&mut self, _workspace: &Path, _team_key: &str) -> bool {
2357
+ true
2358
+ }
2359
+
2360
+ fn canonical_readback(&mut self, _workspace: &Path, _team_key: &str) -> bool {
2361
+ true
2362
+ }
2363
+ }
2364
+
2365
+ fn runtime_workspace(hermetic: &HermeticTestEnv, tag: &str) -> PathBuf {
2366
+ let path = hermetic.workspace(tag);
2367
+ crate::state::persist::save_runtime_state(
2368
+ &path,
2369
+ &json!({
2370
+ "active_team_key": "fresh",
2371
+ "team_key": "fresh",
2372
+ "session_name": "team-fresh",
2373
+ "agents": {"sol": {"status": "running", "provider": "pi"}}
2374
+ }),
2375
+ )
2376
+ .unwrap();
2377
+ path
2378
+ }
2379
+
2380
+ fn refusal_reason(workspace: &Path) -> Option<(String, String)> {
2381
+ let events = crate::event_log::EventLog::new(workspace).tail(0).unwrap();
2382
+ let event = events.last()?;
2383
+ Some((
2384
+ event
2385
+ .get("stage")
2386
+ .and_then(serde_json::Value::as_str)?
2387
+ .to_string(),
2388
+ event
2389
+ .get("reason")
2390
+ .and_then(serde_json::Value::as_str)?
2391
+ .to_string(),
2392
+ ))
2393
+ }
2394
+
2395
+ fn seeded_runtime_owner(workspace: &Path) -> serde_json::Value {
2396
+ let mut state = crate::state::persist::load_runtime_state(workspace).unwrap();
2397
+ assert!(super::spec_state::seed_launched_owner_from_env(
2398
+ &mut state,
2399
+ Some("/tmp/tmux.sock")
2400
+ ));
2401
+ crate::state::persist::save_runtime_state(workspace, &state).unwrap();
2402
+ crate::state::persist::load_runtime_state(workspace)
2403
+ .unwrap()
2404
+ .pointer("/teams/fresh/team_owner")
2405
+ .cloned()
2406
+ .expect("seeded team owner")
2407
+ }
2408
+
2409
+ fn caller_target(path: PathBuf, nonce: Option<&str>) -> PaneInfo {
2410
+ let mut leader_env = BTreeMap::new();
2411
+ if let Some(nonce) = nonce {
2412
+ leader_env.insert(
2413
+ crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY.to_string(),
2414
+ nonce.to_string(),
2415
+ );
2416
+ }
2417
+ PaneInfo {
2418
+ pane_id: PaneId::new("%1"),
2419
+ session: SessionName::new("parent"),
2420
+ window_index: Some(0),
2421
+ window_name: Some(WindowName::new("caller")),
2422
+ pane_index: Some(0),
2423
+ tty: Some("/dev/pts/1".to_string()),
2424
+ current_command: Some("bash".to_string()),
2425
+ current_path: Some(path),
2426
+ active: true,
2427
+ pane_pid: Some(101),
2428
+ leader_env,
2429
+ }
2430
+ }
2431
+
2432
+ #[test]
2433
+ #[serial_test::serial(env)]
2434
+ fn runtime_valid_caller_binds_typed_pi_through_bash() {
2435
+ let hermetic = HermeticTestEnv::enter("runtime-valid-pi-bash");
2436
+ let workspace = runtime_workspace(&hermetic, "fresh");
2437
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
2438
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
2439
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
2440
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
2441
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
2442
+ let transport = OfflineTransport::new()
2443
+ .with_tmux_endpoint("/tmp/tmux.sock")
2444
+ .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);
2449
+ }
2450
+
2451
+ #[test]
2452
+ #[serial_test::serial(env)]
2453
+ fn seeded_runtime_bind_publishes_fresh_caller_authority_for_live_parent_pane() {
2454
+ let hermetic = HermeticTestEnv::enter("runtime-seeded-fresh-authority");
2455
+ let workspace = runtime_workspace(&hermetic, "fresh");
2456
+ let parent = hermetic.root().join("parent");
2457
+ std::fs::create_dir_all(&parent).unwrap();
2458
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
2459
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
2460
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
2461
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
2462
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
2463
+ let seed = seeded_runtime_owner(&workspace);
2464
+ let target = caller_target(parent, Some("shared-live-nonce"));
2465
+ let transport = OfflineTransport::new()
2466
+ .with_tmux_endpoint("/tmp/tmux.sock")
2467
+ .with_pane_current_command("%1", "bash")
2468
+ .with_targets(vec![target.clone()]);
2469
+ let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
2470
+ transport: &transport,
2471
+ last_failure_reason: None,
2472
+ cas_conflict: false,
2473
+ 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
2482
+ )
2483
+ .unwrap());
2484
+ let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
2485
+ let receiver = persisted
2486
+ .pointer("/teams/fresh/leader_receiver")
2487
+ .cloned()
2488
+ .expect("published receiver");
2489
+ assert_eq!(receiver["scope_authority"], json!("fresh_caller"));
2490
+ assert_eq!(receiver["binding_nonce"], json!("shared-live-nonce"));
2491
+ assert_eq!(
2492
+ receiver["authorized_team_workspace"],
2493
+ json!(workspace.to_string_lossy().to_string())
2494
+ );
2495
+ assert_eq!(receiver["owner_epoch"], json!(1));
2496
+ let registry_path = crate::leader::registry::registry_dir()
2497
+ .unwrap()
2498
+ .join(format!(
2499
+ "{}__fresh.json",
2500
+ crate::leader::registry::workspace_hash(&workspace)
2501
+ ));
2502
+ let registry: crate::leader::registry::LeaderRegistryEntry =
2503
+ serde_json::from_slice(&std::fs::read(registry_path).unwrap()).unwrap();
2504
+ assert_eq!(registry.channel["scope_authority"], json!("fresh_caller"));
2505
+ assert!(matches!(
2506
+ crate::messaging::resolve_live_leader_channel(&workspace, &receiver, &transport),
2507
+ crate::messaging::LeaderChannelResolution::Live(_)
2508
+ ));
2509
+
2510
+ let mut stale_target = target.clone();
2511
+ stale_target.leader_env.insert(
2512
+ crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY.to_string(),
2513
+ "stale-live-nonce".to_string(),
2514
+ );
2515
+ let stale_transport = OfflineTransport::new()
2516
+ .with_tmux_endpoint("/tmp/tmux.sock")
2517
+ .with_targets(vec![stale_target]);
2518
+ assert!(matches!(
2519
+ crate::messaging::resolve_live_leader_channel(
2520
+ &workspace,
2521
+ &receiver,
2522
+ &stale_transport
2523
+ ),
2524
+ crate::messaging::LeaderChannelResolution::Unbound(
2525
+ crate::messaging::LeaderChannelUnbound::PaneWorkspaceMismatch(_)
2526
+ )
2527
+ ));
2528
+
2529
+ let missing_target = caller_target(hermetic.root().join("parent"), None);
2530
+ let missing_transport = OfflineTransport::new()
2531
+ .with_tmux_endpoint("/tmp/tmux.sock")
2532
+ .with_targets(vec![missing_target]);
2533
+ assert!(matches!(
2534
+ crate::messaging::resolve_live_leader_channel(
2535
+ &workspace,
2536
+ &receiver,
2537
+ &missing_transport
2538
+ ),
2539
+ crate::messaging::LeaderChannelResolution::Unbound(
2540
+ crate::messaging::LeaderChannelUnbound::PaneWorkspaceMismatch(_)
2541
+ )
2542
+ ));
2543
+
2544
+ let mut missing_authority_receiver = receiver.clone();
2545
+ missing_authority_receiver
2546
+ .as_object_mut()
2547
+ .unwrap()
2548
+ .remove("scope_authority");
2549
+ assert!(matches!(
2550
+ crate::messaging::resolve_live_leader_channel(
2551
+ &workspace,
2552
+ &missing_authority_receiver,
2553
+ &transport
2554
+ ),
2555
+ crate::messaging::LeaderChannelResolution::Unbound(
2556
+ crate::messaging::LeaderChannelUnbound::PaneWorkspaceMismatch(_)
2557
+ )
2558
+ ));
2559
+
2560
+ let mut foreign_receiver = receiver.clone();
2561
+ foreign_receiver["authorized_team_workspace"] =
2562
+ json!(hermetic.root().join("foreign").to_string_lossy().to_string());
2563
+ assert!(matches!(
2564
+ crate::messaging::resolve_live_leader_channel(&workspace, &foreign_receiver, &transport),
2565
+ crate::messaging::LeaderChannelResolution::Unbound(
2566
+ crate::messaging::LeaderChannelUnbound::PaneWorkspaceMismatch(_)
2567
+ )
2568
+ ));
2569
+
2570
+ let wrong_socket_transport = OfflineTransport::new()
2571
+ .with_tmux_endpoint("/tmp/other.sock")
2572
+ .with_targets(vec![target]);
2573
+ assert_eq!(
2574
+ crate::messaging::resolve_live_leader_channel(
2575
+ &workspace,
2576
+ &receiver,
2577
+ &wrong_socket_transport
2578
+ ),
2579
+ crate::messaging::LeaderChannelResolution::Unbound(
2580
+ crate::messaging::LeaderChannelUnbound::EndpointMismatch
2581
+ )
2582
+ );
2583
+ }
2584
+
2585
+ #[test]
2586
+ #[serial_test::serial(env)]
2587
+ fn seeded_runtime_children_reuse_live_pane_nonce_without_overwrite() {
2588
+ let hermetic = HermeticTestEnv::enter("runtime-seeded-shared-nonce");
2589
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
2590
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
2591
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
2592
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
2593
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
2594
+ let parent = hermetic.root().join("parent");
2595
+ std::fs::create_dir_all(&parent).unwrap();
2596
+ let target_without_nonce = caller_target(parent.clone(), None);
2597
+ let target_with_winner = caller_target(parent, Some("writer-winner"));
2598
+ let transport = OfflineTransport::new()
2599
+ .with_tmux_endpoint("/tmp/tmux.sock")
2600
+ .with_pane_current_command("%1", "bash")
2601
+ .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
+ ]);
2608
+ let first_workspace = runtime_workspace(&hermetic, "child-a");
2609
+ let second_workspace = runtime_workspace(&hermetic, "child-b");
2610
+ let first_seed = seeded_runtime_owner(&first_workspace);
2611
+ let second_seed = seeded_runtime_owner(&second_workspace);
2612
+ let mut first_ops = RuntimeFreshQuickStartLeaderBindingOps {
2613
+ transport: &transport,
2614
+ last_failure_reason: None,
2615
+ cas_conflict: false,
2616
+ applied_grant: None,
2617
+ registry_receipt: None,
2618
+ nonce_writer: Some(&nonce_writer_winner),
2619
+ };
2620
+ assert!(bind_fresh_quick_start_leader_with(
2621
+ &first_workspace,
2622
+ "fresh",
2623
+ Some(&first_seed),
2624
+ &mut first_ops
2625
+ )
2626
+ .unwrap());
2627
+ let mut second_ops = RuntimeFreshQuickStartLeaderBindingOps {
2628
+ transport: &transport,
2629
+ last_failure_reason: None,
2630
+ cas_conflict: false,
2631
+ applied_grant: None,
2632
+ registry_receipt: None,
2633
+ nonce_writer: Some(&nonce_writer_winner),
2634
+ };
2635
+ assert!(bind_fresh_quick_start_leader_with(
2636
+ &second_workspace,
2637
+ "fresh",
2638
+ Some(&second_seed),
2639
+ &mut second_ops
2640
+ )
2641
+ .unwrap());
2642
+ let first_receiver = crate::state::persist::load_runtime_state(&first_workspace).unwrap();
2643
+ let second_receiver = crate::state::persist::load_runtime_state(&second_workspace).unwrap();
2644
+ assert_eq!(
2645
+ first_receiver
2646
+ .pointer("/teams/fresh/leader_receiver/binding_nonce")
2647
+ .and_then(serde_json::Value::as_str),
2648
+ Some("writer-winner")
2649
+ );
2650
+ assert_eq!(
2651
+ second_receiver
2652
+ .pointer("/teams/fresh/leader_receiver/binding_nonce")
2653
+ .and_then(serde_json::Value::as_str),
2654
+ Some("writer-winner")
2655
+ );
2656
+ let first_channel = first_receiver
2657
+ .pointer("/teams/fresh/leader_receiver")
2658
+ .expect("first receiver");
2659
+ let second_channel = second_receiver
2660
+ .pointer("/teams/fresh/leader_receiver")
2661
+ .expect("second receiver");
2662
+ assert!(matches!(
2663
+ crate::messaging::resolve_live_leader_channel(
2664
+ &first_workspace,
2665
+ first_channel,
2666
+ &transport
2667
+ ),
2668
+ crate::messaging::LeaderChannelResolution::Live(_)
2669
+ ));
2670
+ assert!(matches!(
2671
+ crate::messaging::resolve_live_leader_channel(
2672
+ &second_workspace,
2673
+ second_channel,
2674
+ &transport
2675
+ ),
2676
+ crate::messaging::LeaderChannelResolution::Live(_)
2677
+ ));
2678
+ }
2679
+
2680
+ struct RuntimeCommitFailureOps<'a> {
2681
+ inner: RuntimeFreshQuickStartLeaderBindingOps<'a>,
2682
+ register_ok: bool,
2683
+ readback_ok: bool,
2684
+ publish_winner_after_register: bool,
2685
+ break_event_log: bool,
2686
+ break_registry_lock: bool,
2687
+ break_registry_read: bool,
2688
+ }
2689
+
2690
+ impl FreshQuickStartLeaderBindingOps for RuntimeCommitFailureOps<'_> {
2691
+ fn caller_pane(&mut self) -> Option<String> {
2692
+ self.inner.caller_pane()
2693
+ }
2694
+
2695
+ fn caller_resolution(&mut self) -> crate::layout::worker_env::CallerProviderResolution {
2696
+ self.inner.caller_resolution()
2697
+ }
2698
+
2699
+ fn explicit_provider(&mut self) -> Option<String> {
2700
+ self.inner.explicit_provider()
2701
+ }
2702
+
2703
+ fn tmux_endpoint(&mut self) -> Option<String> {
2704
+ self.inner.tmux_endpoint()
2705
+ }
2706
+
2707
+ fn observe_command(&mut self, pane: &PaneId) -> Option<String> {
2708
+ self.inner.observe_command(pane)
2709
+ }
2710
+
2711
+ fn attach(
2712
+ &mut self,
2713
+ workspace: &Path,
2714
+ state: &mut serde_json::Value,
2715
+ pane: &PaneId,
2716
+ provider: crate::provider::Provider,
2717
+ ) -> bool {
2718
+ if self.break_event_log {
2719
+ let event_path = crate::model::paths::logs_dir(workspace).join("events.jsonl");
2720
+ let _ = std::fs::remove_file(&event_path);
2721
+ std::fs::create_dir_all(&event_path).unwrap();
2722
+ }
2723
+ self.inner.attach(workspace, state, pane, provider)
2724
+ }
2725
+
2726
+ fn attach_cas_conflict(&self) -> bool {
2727
+ self.inner.attach_cas_conflict()
2728
+ }
2729
+
2730
+ fn applied_grant(&self) -> Option<(serde_json::Value, serde_json::Value)> {
2731
+ self.inner.applied_grant()
2732
+ }
2733
+
2734
+ fn registry_receipt(&self) -> Option<crate::leader::registry::RegistryWriteReceipt> {
2735
+ self.inner.registry_receipt()
2736
+ }
2737
+
2738
+ fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
2739
+ let registered = self.inner.register(workspace, team_key);
2740
+ if !self.register_ok {
2741
+ return false;
2742
+ }
2743
+ if self.break_registry_lock {
2744
+ let lock_path = crate::leader::registry::registry_dir()
2745
+ .expect("registry dir")
2746
+ .join(".registry.lock");
2747
+ let _ = std::fs::remove_file(&lock_path);
2748
+ std::fs::create_dir_all(&lock_path).unwrap();
2749
+ }
2750
+ if self.break_registry_read {
2751
+ if let Some(receipt) = self.inner.registry_receipt() {
2752
+ let _ = std::fs::remove_file(&receipt.path);
2753
+ std::fs::create_dir_all(&receipt.path).unwrap();
2754
+ }
2755
+ }
2756
+ if registered && self.publish_winner_after_register {
2757
+ let mut winner = crate::state::persist::load_runtime_state(workspace).unwrap();
2758
+ winner["teams"][team_key]["leader_receiver"]["pane_id"] = json!("%winner");
2759
+ crate::state::persist::save_runtime_state_with_receiver_authority(
2760
+ workspace,
2761
+ &winner,
2762
+ team_key,
2763
+ None,
2764
+ )
2765
+ .unwrap();
2766
+ let _ = crate::leader::registry::register_binding_from_state_best_effort(
2767
+ workspace,
2768
+ Some(team_key),
2769
+ "concurrent",
2770
+ );
2771
+ }
2772
+ registered
2773
+ }
2774
+
2775
+ fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
2776
+ self.readback_ok && self.inner.canonical_readback(workspace, team_key)
2777
+ }
2778
+ }
2779
+
2780
+ #[test]
2781
+ #[serial_test::serial(env)]
2782
+ fn seeded_runtime_register_failure_rolls_back_child_authority() {
2783
+ let hermetic = HermeticTestEnv::enter("runtime-seeded-rollback");
2784
+ let workspace = runtime_workspace(&hermetic, "fresh");
2785
+ let parent = hermetic.root().join("parent");
2786
+ std::fs::create_dir_all(&parent).unwrap();
2787
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
2788
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
2789
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
2790
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
2791
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
2792
+ let seed = seeded_runtime_owner(&workspace);
2793
+ let target_without_nonce = caller_target(parent.clone(), None);
2794
+ let target_with_nonce = caller_target(parent, Some("writer-winner"));
2795
+ let transport = OfflineTransport::new()
2796
+ .with_tmux_endpoint("/tmp/tmux.sock")
2797
+ .with_pane_current_command("%1", "bash")
2798
+ .with_targets(vec![target_with_nonce.clone()])
2799
+ .with_target_snapshots(vec![
2800
+ vec![target_without_nonce],
2801
+ vec![target_with_nonce],
2802
+ ]);
2803
+ let mut ops = RuntimeCommitFailureOps {
2804
+ inner: RuntimeFreshQuickStartLeaderBindingOps {
2805
+ transport: &transport,
2806
+ last_failure_reason: None,
2807
+ cas_conflict: false,
2808
+ applied_grant: None,
2809
+ registry_receipt: None,
2810
+ nonce_writer: Some(&nonce_writer_winner),
2811
+ },
2812
+ register_ok: false,
2813
+ readback_ok: true,
2814
+ publish_winner_after_register: false,
2815
+ break_event_log: false,
2816
+ break_registry_lock: false,
2817
+ break_registry_read: false,
2818
+ };
2819
+ assert!(!bind_fresh_quick_start_leader_with(
2820
+ &workspace,
2821
+ "fresh",
2822
+ Some(&seed),
2823
+ &mut ops
2824
+ )
2825
+ .unwrap());
2826
+ let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
2827
+ assert!(persisted
2828
+ .pointer("/teams/fresh/team_owner")
2829
+ .is_some_and(serde_json::Value::is_null));
2830
+ assert!(persisted
2831
+ .pointer("/teams/fresh/leader_receiver")
2832
+ .is_some_and(serde_json::Value::is_null));
2833
+ assert!(!persisted.to_string().contains("fresh_caller"));
2834
+ let registry_path = crate::leader::registry::registry_dir()
2835
+ .unwrap()
2836
+ .join(format!(
2837
+ "{}__fresh.json",
2838
+ crate::leader::registry::workspace_hash(&workspace)
2839
+ ));
2840
+ assert!(!registry_path.exists(), "failed child grant left registry row");
2841
+ let observed = transport.list_targets().unwrap();
2842
+ assert_eq!(
2843
+ observed[0]
2844
+ .leader_env
2845
+ .get(crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY)
2846
+ .map(String::as_str),
2847
+ Some("writer-winner"),
2848
+ "failed child bind must not remove the shared first-writer marker"
2849
+ );
2850
+ }
2851
+
2852
+ #[test]
2853
+ #[serial_test::serial(env)]
2854
+ fn seeded_runtime_post_save_event_error_rolls_back_applied_grant() {
2855
+ let hermetic = HermeticTestEnv::enter("runtime-seeded-post-save-event-error");
2856
+ let workspace = runtime_workspace(&hermetic, "fresh");
2857
+ let parent = hermetic.root().join("parent");
2858
+ std::fs::create_dir_all(&parent).unwrap();
2859
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
2860
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
2861
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
2862
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
2863
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
2864
+ let seed = seeded_runtime_owner(&workspace);
2865
+ let target = caller_target(parent, Some("post-save-event-nonce"));
2866
+ let transport = OfflineTransport::new()
2867
+ .with_tmux_endpoint("/tmp/tmux.sock")
2868
+ .with_pane_current_command("%1", "bash")
2869
+ .with_targets(vec![target]);
2870
+ let mut ops = RuntimeCommitFailureOps {
2871
+ inner: RuntimeFreshQuickStartLeaderBindingOps {
2872
+ transport: &transport,
2873
+ last_failure_reason: None,
2874
+ cas_conflict: false,
2875
+ applied_grant: None,
2876
+ registry_receipt: None,
2877
+ nonce_writer: None,
2878
+ },
2879
+ register_ok: true,
2880
+ readback_ok: true,
2881
+ publish_winner_after_register: false,
2882
+ break_event_log: true,
2883
+ break_registry_lock: false,
2884
+ break_registry_read: false,
2885
+ };
2886
+ assert!(!bind_fresh_quick_start_leader_with(
2887
+ &workspace,
2888
+ "fresh",
2889
+ Some(&seed),
2890
+ &mut ops,
2891
+ )
2892
+ .unwrap());
2893
+ assert_eq!(ops.inner.last_failure_reason, Some("attach_event_log_failed"));
2894
+ let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
2895
+ assert!(persisted
2896
+ .pointer("/teams/fresh/team_owner")
2897
+ .is_some_and(serde_json::Value::is_null));
2898
+ assert!(persisted
2899
+ .pointer("/teams/fresh/leader_receiver")
2900
+ .is_some_and(serde_json::Value::is_null));
2901
+ assert!(!persisted.to_string().contains("fresh_caller"));
2902
+ let registry_path = crate::leader::registry::registry_dir()
2903
+ .unwrap()
2904
+ .join(format!(
2905
+ "{}__fresh.json",
2906
+ crate::leader::registry::workspace_hash(&workspace)
2907
+ ));
2908
+ assert!(!registry_path.exists());
2909
+ }
2910
+
2911
+ #[test]
2912
+ #[serial_test::serial(env)]
2913
+ fn seeded_runtime_nonce_change_before_readback_rolls_back_child_authority() {
2914
+ let hermetic = HermeticTestEnv::enter("runtime-seeded-nonce-drift");
2915
+ let workspace = runtime_workspace(&hermetic, "fresh");
2916
+ let parent = hermetic.root().join("parent");
2917
+ std::fs::create_dir_all(&parent).unwrap();
2918
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
2919
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
2920
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
2921
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
2922
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
2923
+ let seed = seeded_runtime_owner(&workspace);
2924
+ let first_target = caller_target(parent.clone(), Some("before-readback"));
2925
+ let mut changed_target = first_target.clone();
2926
+ changed_target.leader_env.insert(
2927
+ crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY.to_string(),
2928
+ "after-readback".to_string(),
2929
+ );
2930
+ let transport = OfflineTransport::new()
2931
+ .with_tmux_endpoint("/tmp/tmux.sock")
2932
+ .with_pane_current_command("%1", "bash")
2933
+ .with_target_snapshots(vec![vec![first_target], vec![changed_target]]);
2934
+ let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
2935
+ transport: &transport,
2936
+ last_failure_reason: None,
2937
+ cas_conflict: false,
2938
+ applied_grant: None,
2939
+ registry_receipt: None,
2940
+ nonce_writer: None,
2941
+ };
2942
+ assert!(!bind_fresh_quick_start_leader_with(
2943
+ &workspace,
2944
+ "fresh",
2945
+ Some(&seed),
2946
+ &mut ops
2947
+ )
2948
+ .unwrap());
2949
+ assert_eq!(ops.last_failure_reason, Some("receiver_live_channel_unavailable"));
2950
+ let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
2951
+ assert!(persisted
2952
+ .pointer("/teams/fresh/team_owner")
2953
+ .is_some_and(serde_json::Value::is_null));
2954
+ assert!(persisted
2955
+ .pointer("/teams/fresh/leader_receiver")
2956
+ .is_some_and(serde_json::Value::is_null));
2957
+ let registry_path = crate::leader::registry::registry_dir()
2958
+ .unwrap()
2959
+ .join(format!(
2960
+ "{}__fresh.json",
2961
+ crate::leader::registry::workspace_hash(&workspace)
2962
+ ));
2963
+ assert!(!registry_path.exists());
2964
+ }
2965
+
2966
+ #[test]
2967
+ #[serial_test::serial(env)]
2968
+ fn seeded_runtime_readback_failure_rolls_back_registry_and_child_authority() {
2969
+ let hermetic = HermeticTestEnv::enter("runtime-seeded-readback-rollback");
2970
+ let workspace = runtime_workspace(&hermetic, "fresh");
2971
+ let parent = hermetic.root().join("parent");
2972
+ std::fs::create_dir_all(&parent).unwrap();
2973
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
2974
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
2975
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
2976
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
2977
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
2978
+ let seed = seeded_runtime_owner(&workspace);
2979
+ let target = caller_target(parent, Some("readback-nonce"));
2980
+ let transport = OfflineTransport::new()
2981
+ .with_tmux_endpoint("/tmp/tmux.sock")
2982
+ .with_pane_current_command("%1", "bash")
2983
+ .with_targets(vec![target]);
2984
+ let mut ops = RuntimeCommitFailureOps {
2985
+ inner: RuntimeFreshQuickStartLeaderBindingOps {
2986
+ transport: &transport,
2987
+ last_failure_reason: None,
2988
+ cas_conflict: false,
2989
+ applied_grant: None,
2990
+ registry_receipt: None,
2991
+ nonce_writer: None,
2992
+ },
2993
+ register_ok: true,
2994
+ readback_ok: false,
2995
+ publish_winner_after_register: false,
2996
+ break_event_log: false,
2997
+ break_registry_lock: false,
2998
+ break_registry_read: false,
2999
+ };
3000
+ assert!(!bind_fresh_quick_start_leader_with(
3001
+ &workspace,
3002
+ "fresh",
3003
+ Some(&seed),
3004
+ &mut ops
3005
+ )
3006
+ .unwrap());
3007
+ let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
3008
+ assert!(persisted
3009
+ .pointer("/teams/fresh/team_owner")
3010
+ .is_some_and(serde_json::Value::is_null));
3011
+ assert!(persisted
3012
+ .pointer("/teams/fresh/leader_receiver")
3013
+ .is_some_and(serde_json::Value::is_null));
3014
+ let registry_path = crate::leader::registry::registry_dir()
3015
+ .unwrap()
3016
+ .join(format!(
3017
+ "{}__fresh.json",
3018
+ crate::leader::registry::workspace_hash(&workspace)
3019
+ ));
3020
+ assert!(!registry_path.exists(), "readback failure left registry row");
3021
+ }
3022
+
3023
+ #[test]
3024
+ #[serial_test::serial(env)]
3025
+ fn seeded_runtime_readback_rollback_preserves_concurrent_registry_winner() {
3026
+ let hermetic = HermeticTestEnv::enter("runtime-seeded-readback-winner");
3027
+ let workspace = runtime_workspace(&hermetic, "fresh");
3028
+ let parent = hermetic.root().join("parent");
3029
+ std::fs::create_dir_all(&parent).unwrap();
3030
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3031
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
3032
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
3033
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
3034
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
3035
+ let seed = seeded_runtime_owner(&workspace);
3036
+ let target = caller_target(parent, Some("readback-winner-nonce"));
3037
+ let transport = OfflineTransport::new()
3038
+ .with_tmux_endpoint("/tmp/tmux.sock")
3039
+ .with_pane_current_command("%1", "bash")
3040
+ .with_targets(vec![target]);
3041
+ let mut ops = RuntimeCommitFailureOps {
3042
+ inner: RuntimeFreshQuickStartLeaderBindingOps {
3043
+ transport: &transport,
3044
+ last_failure_reason: None,
3045
+ cas_conflict: false,
3046
+ applied_grant: None,
3047
+ registry_receipt: None,
3048
+ nonce_writer: None,
3049
+ },
3050
+ register_ok: true,
3051
+ readback_ok: false,
3052
+ publish_winner_after_register: true,
3053
+ break_event_log: false,
3054
+ break_registry_lock: false,
3055
+ break_registry_read: false,
3056
+ };
3057
+ assert!(!bind_fresh_quick_start_leader_with(
3058
+ &workspace,
3059
+ "fresh",
3060
+ Some(&seed),
3061
+ &mut ops,
3062
+ )
3063
+ .unwrap());
3064
+ let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
3065
+ assert_eq!(
3066
+ persisted
3067
+ .pointer("/teams/fresh/team_owner/pane_id")
3068
+ .and_then(serde_json::Value::as_str),
3069
+ Some("%1"),
3070
+ "same-owner winner must retain the owner"
3071
+ );
3072
+ assert_eq!(
3073
+ persisted
3074
+ .pointer("/teams/fresh/leader_receiver/pane_id")
3075
+ .and_then(serde_json::Value::as_str),
3076
+ Some("%winner"),
3077
+ "rollback must not clear a newer receiver"
3078
+ );
3079
+ assert_eq!(
3080
+ persisted
3081
+ .pointer("/teams/fresh/leader_receiver/owner_epoch")
3082
+ .and_then(serde_json::Value::as_u64),
3083
+ Some(1),
3084
+ "rollback must preserve the winner epoch"
3085
+ );
3086
+ let registry_path = crate::leader::registry::registry_dir()
3087
+ .unwrap()
3088
+ .join(format!(
3089
+ "{}__fresh.json",
3090
+ crate::leader::registry::workspace_hash(&workspace)
3091
+ ));
3092
+ let winner: crate::leader::registry::LeaderRegistryEntry =
3093
+ serde_json::from_slice(&std::fs::read(registry_path).unwrap()).unwrap();
3094
+ assert_eq!(winner.channel["pane_id"], json!("%winner"));
3095
+ assert_eq!(winner.source, "concurrent");
3096
+ }
3097
+
3098
+ #[test]
3099
+ #[serial_test::serial(env)]
3100
+ fn seeded_runtime_registry_rollback_errors_are_visible_after_state_cleanup() {
3101
+ for (tag, break_registry_lock, break_registry_read) in [
3102
+ ("lock", true, false),
3103
+ ("read", false, true),
3104
+ ] {
3105
+ let hermetic = HermeticTestEnv::enter(&format!(
3106
+ "runtime-seeded-registry-rollback-{tag}"
3107
+ ));
3108
+ let workspace = runtime_workspace(&hermetic, "fresh");
3109
+ let parent = hermetic.root().join("parent");
3110
+ std::fs::create_dir_all(&parent).unwrap();
3111
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3112
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
3113
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
3114
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
3115
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
3116
+ let seed = seeded_runtime_owner(&workspace);
3117
+ let target = caller_target(parent, Some("rollback-error-nonce"));
3118
+ let transport = OfflineTransport::new()
3119
+ .with_tmux_endpoint("/tmp/tmux.sock")
3120
+ .with_pane_current_command("%1", "bash")
3121
+ .with_targets(vec![target]);
3122
+ let mut ops = RuntimeCommitFailureOps {
3123
+ inner: RuntimeFreshQuickStartLeaderBindingOps {
3124
+ transport: &transport,
3125
+ last_failure_reason: None,
3126
+ cas_conflict: false,
3127
+ applied_grant: None,
3128
+ registry_receipt: None,
3129
+ nonce_writer: None,
3130
+ },
3131
+ register_ok: true,
3132
+ readback_ok: false,
3133
+ publish_winner_after_register: false,
3134
+ break_event_log: false,
3135
+ break_registry_lock,
3136
+ break_registry_read,
3137
+ };
3138
+ let error = bind_fresh_quick_start_leader_with(
3139
+ &workspace,
3140
+ "fresh",
3141
+ Some(&seed),
3142
+ &mut ops,
3143
+ )
3144
+ .expect_err("registry rollback failure must be visible");
3145
+ assert!(error
3146
+ .to_string()
3147
+ .contains("quick-start registry rollback failed"));
3148
+ let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
3149
+ assert!(persisted
3150
+ .pointer("/teams/fresh/team_owner")
3151
+ .is_some_and(serde_json::Value::is_null));
3152
+ assert!(persisted
3153
+ .pointer("/teams/fresh/leader_receiver")
3154
+ .is_some_and(serde_json::Value::is_null));
3155
+ assert!(!persisted.to_string().contains("fresh_caller"));
3156
+ }
3157
+ }
3158
+
3159
+ fn nonce_writer_winner(
3160
+ _endpoint: &str,
3161
+ _pane: &PaneId,
3162
+ _candidate: &str,
3163
+ ) -> Result<String, crate::leader::LeaderError> {
3164
+ Ok("writer-winner".to_string())
3165
+ }
3166
+
3167
+ fn nonce_writer_failure(
3168
+ _endpoint: &str,
3169
+ _pane: &PaneId,
3170
+ _candidate: &str,
3171
+ ) -> Result<String, crate::leader::LeaderError> {
3172
+ Err(crate::leader::LeaderError::Tmux(
3173
+ "conditional nonce writer failed".to_string(),
3174
+ ))
3175
+ }
3176
+
3177
+ #[test]
3178
+ #[serial_test::serial(env)]
3179
+ fn runtime_missing_verified_pane_observation_refuses_and_cleans_seed() {
3180
+ for (tag, observation, expected_reason) in [
3181
+ ("list-error", "list-error", "caller_pane_unobservable"),
3182
+ ("pane-absent", "pane-absent", "caller_pane_not_live"),
3183
+ ("cwd-missing", "cwd-missing", "caller_cwd_unobservable"),
3184
+ ] {
3185
+ let hermetic = HermeticTestEnv::enter(&format!("runtime-observation-{tag}"));
3186
+ let workspace = runtime_workspace(&hermetic, "fresh");
3187
+ let parent = hermetic.root().join("parent");
3188
+ std::fs::create_dir_all(&parent).unwrap();
3189
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3190
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
3191
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
3192
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
3193
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
3194
+ let seed = seeded_runtime_owner(&workspace);
3195
+ let mut transport = OfflineTransport::new()
3196
+ .with_tmux_endpoint("/tmp/tmux.sock")
3197
+ .with_pane_current_command("%1", "bash");
3198
+ match observation {
3199
+ "list-error" => {
3200
+ transport = transport.with_list_targets_error("pane query failed");
3201
+ }
3202
+ "pane-absent" => {}
3203
+ "cwd-missing" => {
3204
+ let mut target = caller_target(parent, None);
3205
+ target.current_path = None;
3206
+ transport = transport.with_targets(vec![target]);
3207
+ }
3208
+ _ => unreachable!(),
3209
+ }
3210
+ let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
3211
+ transport: &transport,
3212
+ last_failure_reason: None,
3213
+ cas_conflict: false,
3214
+ applied_grant: None,
3215
+ registry_receipt: None,
3216
+ nonce_writer: None,
3217
+ };
3218
+ assert!(!bind_fresh_quick_start_leader_with(
3219
+ &workspace,
3220
+ "fresh",
3221
+ Some(&seed),
3222
+ &mut ops
3223
+ )
3224
+ .unwrap());
3225
+ assert_eq!(ops.last_failure_reason, Some(expected_reason));
3226
+ assert_eq!(refusal_reason(&workspace).map(|(_, reason)| reason), Some(expected_reason.to_string()));
3227
+ let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
3228
+ assert!(persisted
3229
+ .pointer("/teams/fresh/team_owner")
3230
+ .is_some_and(serde_json::Value::is_null));
3231
+ assert!(persisted
3232
+ .pointer("/teams/fresh/leader_receiver")
3233
+ .is_some_and(serde_json::Value::is_null));
3234
+ }
3235
+ }
3236
+
3237
+ #[test]
3238
+ #[serial_test::serial(env)]
3239
+ fn first_nonce_writer_is_product_generated_and_consumer_live() {
3240
+ let hermetic = HermeticTestEnv::enter("runtime-first-nonce-writer");
3241
+ let workspace = runtime_workspace(&hermetic, "fresh");
3242
+ let parent = hermetic.root().join("parent");
3243
+ std::fs::create_dir_all(&parent).unwrap();
3244
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3245
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
3246
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
3247
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
3248
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
3249
+ let seed = seeded_runtime_owner(&workspace);
3250
+ let mut state = crate::state::projection::resolve_runtime_team_scope(
3251
+ &workspace,
3252
+ Some("fresh"),
3253
+ )
3254
+ .unwrap()
3255
+ .state;
3256
+ let expected_owner = state["team_owner"].clone();
3257
+ let expected_receiver = state["leader_receiver"].clone();
3258
+ let target = caller_target(parent.clone(), None);
3259
+ let event_log = crate::event_log::EventLog::new(&workspace);
3260
+ let (receiver, _) = crate::leader::attach_leader_to_state_with_target_and_controls(
3261
+ &workspace,
3262
+ &mut state,
3263
+ Some(&PaneId::new("%1")),
3264
+ crate::provider::Provider::Pi,
3265
+ &event_log,
3266
+ crate::leader::LeaseSource::QuickStart,
3267
+ true,
3268
+ Some(&target),
3269
+ Some(&expected_owner),
3270
+ Some(&expected_receiver),
3271
+ Some(&nonce_writer_winner),
3272
+ )
3273
+ .unwrap();
3274
+ let receiver = serde_json::to_value(receiver).unwrap();
3275
+ assert_eq!(receiver["binding_nonce"], json!("writer-winner"));
3276
+ let observed = caller_target(parent, Some("writer-winner"));
3277
+ let transport = OfflineTransport::new()
3278
+ .with_tmux_endpoint("/tmp/tmux.sock")
3279
+ .with_targets(vec![observed]);
3280
+ assert!(matches!(
3281
+ crate::messaging::resolve_live_leader_channel(&workspace, &receiver, &transport),
3282
+ crate::messaging::LeaderChannelResolution::Live(_)
3283
+ ));
3284
+
3285
+ let workspace = runtime_workspace(&hermetic, "writer-failure");
3286
+ let seed = seeded_runtime_owner(&workspace);
3287
+ let mut state = crate::state::projection::resolve_runtime_team_scope(
3288
+ &workspace,
3289
+ Some("fresh"),
3290
+ )
3291
+ .unwrap()
3292
+ .state;
3293
+ let expected_owner = state["team_owner"].clone();
3294
+ let expected_receiver = state["leader_receiver"].clone();
3295
+ let target = caller_target(hermetic.root().join("parent"), None);
3296
+ let result = crate::leader::attach_leader_to_state_with_target_and_controls(
3297
+ &workspace,
3298
+ &mut state,
3299
+ Some(&PaneId::new("%1")),
3300
+ crate::provider::Provider::Pi,
3301
+ &crate::event_log::EventLog::new(&workspace),
3302
+ crate::leader::LeaseSource::QuickStart,
3303
+ true,
3304
+ Some(&target),
3305
+ Some(&expected_owner),
3306
+ Some(&expected_receiver),
3307
+ Some(&nonce_writer_failure),
3308
+ );
3309
+ assert!(result.is_err());
3310
+ let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
3311
+ assert_eq!(
3312
+ persisted.pointer("/teams/fresh/leader_receiver/binding_nonce"),
3313
+ None
3314
+ );
3315
+ assert!(!persisted.to_string().contains("fresh_caller"));
3316
+ let _ = seed;
3317
+ }
3318
+
3319
+ #[test]
3320
+ #[serial_test::serial(env)]
3321
+ fn seeded_cas_conflict_preserves_same_owner_receiver_and_registry_through_bind_rollback() {
3322
+ let hermetic = HermeticTestEnv::enter("runtime-seeded-cas-winner");
3323
+ let workspace = runtime_workspace(&hermetic, "fresh");
3324
+ let parent = hermetic.root().join("parent");
3325
+ std::fs::create_dir_all(&parent).unwrap();
3326
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3327
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
3328
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
3329
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
3330
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, "/tmp/tmux.sock");
3331
+ let seed = seeded_runtime_owner(&workspace);
3332
+ let concurrent_writer = |_: &str,
3333
+ _: &PaneId,
3334
+ _: &str|
3335
+ -> Result<String, crate::leader::LeaderError> {
3336
+ let mut winner = crate::state::persist::load_runtime_state(&workspace)
3337
+ .map_err(crate::leader::LeaderError::State)?;
3338
+ winner["teams"]["fresh"]["leader_receiver"]["pane_id"] = json!("%winner");
3339
+ crate::state::persist::save_runtime_state_with_receiver_authority(
3340
+ &workspace,
3341
+ &winner,
3342
+ "fresh",
3343
+ None,
3344
+ )
3345
+ .map_err(crate::leader::LeaderError::State)?;
3346
+ let _ = crate::leader::registry::register_binding_from_state_best_effort(
3347
+ &workspace,
3348
+ Some("fresh"),
3349
+ "concurrent",
3350
+ );
3351
+ Ok("writer-winner".to_string())
3352
+ };
3353
+ let target = caller_target(parent, None);
3354
+ let transport = OfflineTransport::new()
3355
+ .with_tmux_endpoint("/tmp/tmux.sock")
3356
+ .with_pane_current_command("%1", "bash")
3357
+ .with_targets(vec![target]);
3358
+ let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
3359
+ transport: &transport,
3360
+ last_failure_reason: None,
3361
+ cas_conflict: false,
3362
+ applied_grant: None,
3363
+ registry_receipt: None,
3364
+ nonce_writer: Some(&concurrent_writer),
3365
+ };
3366
+ assert!(!bind_fresh_quick_start_leader_with(
3367
+ &workspace,
3368
+ "fresh",
3369
+ Some(&seed),
3370
+ &mut ops
3371
+ )
3372
+ .unwrap());
3373
+ assert!(ops.cas_conflict);
3374
+ assert_eq!(ops.last_failure_reason, Some("attach_cas_conflict"));
3375
+ let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
3376
+ assert_eq!(
3377
+ persisted.pointer("/teams/fresh/team_owner/pane_id").and_then(serde_json::Value::as_str),
3378
+ Some("%1")
3379
+ );
3380
+ assert_eq!(
3381
+ persisted.pointer("/teams/fresh/leader_receiver/pane_id").and_then(serde_json::Value::as_str),
3382
+ Some("%winner")
3383
+ );
3384
+ assert_eq!(
3385
+ persisted.pointer("/teams/fresh/team_owner/owner_epoch").and_then(serde_json::Value::as_u64),
3386
+ Some(1)
3387
+ );
3388
+ assert_eq!(
3389
+ persisted.pointer("/teams/fresh/leader_receiver/owner_epoch").and_then(serde_json::Value::as_u64),
3390
+ Some(1)
3391
+ );
3392
+ let registry_path = crate::leader::registry::registry_dir()
3393
+ .unwrap()
3394
+ .join(format!(
3395
+ "{}__fresh.json",
3396
+ crate::leader::registry::workspace_hash(&workspace)
3397
+ ));
3398
+ assert!(registry_path.exists(), "concurrent winner registry was removed");
3399
+ let winner_entry: crate::leader::registry::LeaderRegistryEntry =
3400
+ serde_json::from_slice(&std::fs::read(registry_path).unwrap()).unwrap();
3401
+ assert_eq!(winner_entry.channel["pane_id"], json!("%winner"));
3402
+ }
3403
+
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
+ #[test]
3431
+ #[serial_test::serial(env)]
3432
+ fn runtime_socket_and_explicit_conflicts_refuse_without_command_fallback() {
3433
+ for (tag, endpoint, leader, scoped) in [
3434
+ (
3435
+ "socket",
3436
+ "/tmp/other.sock",
3437
+ None,
3438
+ "/tmp/tmux.sock",
3439
+ ),
3440
+ (
3441
+ "scoped",
3442
+ "/tmp/tmux.sock",
3443
+ None,
3444
+ "/tmp/product.sock",
3445
+ ),
3446
+ (
3447
+ "leader",
3448
+ "/tmp/tmux.sock",
3449
+ Some("codex"),
3450
+ "/tmp/tmux.sock",
3451
+ ),
3452
+ ] {
3453
+ let hermetic = HermeticTestEnv::enter(&format!("runtime-conflict-{tag}"));
3454
+ let workspace = runtime_workspace(&hermetic, tag);
3455
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3456
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
3457
+ let _provider = hermetic.with_env(CALLER_PROVIDER_ENV, "pi");
3458
+ let _caller_pane = hermetic.with_env(CALLER_PANE_ENV, "%1");
3459
+ let _caller_endpoint = hermetic.with_env(CALLER_ENDPOINT_ENV, endpoint);
3460
+ let _leader = leader.map(|value| hermetic.with_env("TEAM_AGENT_LEADER_PROVIDER", value));
3461
+ let transport = OfflineTransport::new()
3462
+ .with_tmux_endpoint(scoped)
3463
+ .with_pane_current_command("%1", "pi");
3464
+ let mut ops = RecordingRuntimeOps::new(&transport);
3465
+ assert!(
3466
+ !bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap(),
3467
+ "{tag} must refuse"
3468
+ );
3469
+ assert_eq!(ops.attach_calls, 0, "{tag}");
3470
+ }
3471
+ }
3472
+
3473
+ #[test]
3474
+ #[serial_test::serial(env)]
3475
+ fn runtime_absent_keeps_explicit_and_direct_paths() {
3476
+ let hermetic = HermeticTestEnv::enter("runtime-absent-compat");
3477
+ let workspace = runtime_workspace(&hermetic, "explicit");
3478
+ let _tmux = hermetic.with_env("TMUX", "/tmp/tmux.sock,1,0");
3479
+ let _pane = hermetic.with_env("TMUX_PANE", "%1");
3480
+ let _leader = hermetic.with_env("TEAM_AGENT_LEADER_PROVIDER", "pi");
3481
+ let transport = OfflineTransport::new()
3482
+ .with_tmux_endpoint("/tmp/tmux.sock")
3483
+ .with_pane_current_command("%1", "bash");
3484
+ let mut ops = RecordingRuntimeOps::new(&transport);
3485
+ assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
3486
+ assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
3487
+
3488
+ let workspace = runtime_workspace(&hermetic, "direct");
3489
+ drop(_leader);
3490
+ let transport = OfflineTransport::new()
3491
+ .with_tmux_endpoint("/tmp/tmux.sock")
3492
+ .with_pane_current_command("%1", "pi");
3493
+ let mut ops = RecordingRuntimeOps::new(&transport);
3494
+ assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
3495
+ assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
3496
+
3497
+ let workspace = runtime_workspace(&hermetic, "unknown-shell");
3498
+ let transport = OfflineTransport::new()
3499
+ .with_tmux_endpoint("/tmp/tmux.sock")
3500
+ .with_pane_current_command("%1", "bash");
3501
+ let mut ops = RecordingRuntimeOps::new(&transport);
3502
+ assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops).unwrap());
3503
+ assert_eq!(ops.attach_calls, 0);
3504
+ assert_eq!(
3505
+ refusal_reason(&workspace),
3506
+ Some((
3507
+ "strict_provider".to_string(),
3508
+ "provider_unresolved".to_string()
3509
+ ))
3510
+ );
3511
+ }
1752
3512
  }