@team-agent/installer 0.5.73 → 0.5.75

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 (38) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +19 -13
  4. package/crates/team-agent/src/cli/emit.rs +6 -3
  5. package/crates/team-agent/src/cli/mod.rs +151 -0
  6. package/crates/team-agent/src/cli/tests/leader_watch.rs +28 -0
  7. package/crates/team-agent/src/cli/tests/run_delegation.rs +3 -0
  8. package/crates/team-agent/src/cli/types.rs +6 -0
  9. package/crates/team-agent/src/compiler.rs +14 -1
  10. package/crates/team-agent/src/layout/worker_env.rs +366 -0
  11. package/crates/team-agent/src/leader/lease.rs +334 -53
  12. package/crates/team-agent/src/leader/registry.rs +177 -108
  13. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +12 -1
  14. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +2272 -128
  15. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +16 -6
  16. package/crates/team-agent/src/lifecycle/launch/spawn.rs +4 -0
  17. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +138 -13
  18. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +5 -3
  19. package/crates/team-agent/src/lifecycle/restart/common.rs +3 -11
  20. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +60 -0
  21. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +31 -0
  22. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +25 -23
  23. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +110 -31
  24. package/crates/team-agent/src/messaging/delivery.rs +226 -37
  25. package/crates/team-agent/src/messaging/leader_channel.rs +12 -3
  26. package/crates/team-agent/src/messaging/tests/runtime.rs +234 -0
  27. package/crates/team-agent/src/model/spec.rs +81 -7
  28. package/crates/team-agent/src/provider/adapters/pi.rs +14 -0
  29. package/crates/team-agent/src/state/persist.rs +433 -4
  30. package/crates/team-agent/src/state/repository.rs +45 -2
  31. package/crates/team-agent/src/tmux_backend/tests.rs +414 -7
  32. package/crates/team-agent/src/tmux_backend.rs +274 -26
  33. package/crates/team-agent/src/transport/test_support.rs +18 -3
  34. package/crates/team-agent/src/transport.rs +99 -0
  35. package/package.json +4 -4
  36. package/skills/team-agent/SKILL.md +3 -0
  37. package/skills/team-agent/command-coverage.json +21 -0
  38. package/skills/team-agent/references/team-agent-operator.md +9 -2
@@ -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,10 +42,12 @@ 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>;
44
- fn live_binding_elsewhere(&mut self, pane: &PaneId, workspace: &Path, team_key: &str) -> bool;
45
51
  fn attach(
46
52
  &mut self,
47
53
  workspace: &Path,
@@ -49,12 +55,36 @@ trait FreshQuickStartLeaderBindingOps {
49
55
  pane: &PaneId,
50
56
  provider: crate::provider::Provider,
51
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
+ }
52
70
  fn register(&mut self, workspace: &Path, team_key: &str) -> bool;
71
+ fn register_failure_reason(&self) -> Option<&'static str> {
72
+ None
73
+ }
53
74
  fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool;
75
+ fn readback_failure_reason(&self) -> Option<&'static str> {
76
+ None
77
+ }
54
78
  }
55
79
 
56
80
  struct RuntimeFreshQuickStartLeaderBindingOps<'a> {
57
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>>,
58
88
  }
59
89
 
60
90
  impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<'_> {
@@ -64,6 +94,18 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
64
94
  .filter(|pane| !pane.is_empty())
65
95
  }
66
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
+
67
109
  fn explicit_provider(&mut self) -> Option<String> {
68
110
  std::env::var("TEAM_AGENT_LEADER_PROVIDER")
69
111
  .ok()
@@ -82,14 +124,6 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
82
124
  .filter(|command| !command.trim().is_empty())
83
125
  }
84
126
 
85
- fn live_binding_elsewhere(&mut self, pane: &PaneId, workspace: &Path, team_key: &str) -> bool {
86
- crate::leader::registry::live_same_pane_binding_elsewhere(
87
- pane.as_str(),
88
- workspace,
89
- team_key,
90
- )
91
- }
92
-
93
127
  fn attach(
94
128
  &mut self,
95
129
  workspace: &Path,
@@ -97,8 +131,36 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
97
131
  pane: &PaneId,
98
132
  provider: crate::provider::Provider,
99
133
  ) -> bool {
134
+ self.cas_conflict = false;
135
+ self.applied_grant = None;
136
+ self.registry_receipt = None;
100
137
  let event_log = crate::event_log::EventLog::new(workspace);
101
- 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(
102
164
  workspace,
103
165
  state,
104
166
  Some(pane),
@@ -106,24 +168,159 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
106
168
  &event_log,
107
169
  crate::leader::LeaseSource::QuickStart,
108
170
  true,
109
- )
110
- .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()
111
225
  }
112
226
 
113
227
  fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
114
- crate::leader::registry::register_binding_from_state_best_effort(
228
+ match crate::leader::registry::register_binding_from_state_best_effort(
115
229
  workspace,
116
230
  Some(team_key),
117
231
  "quick-start",
118
- )
119
- .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
120
255
  }
121
256
 
122
257
  fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
123
- 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
124
290
  }
125
291
  }
126
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
+
127
324
  struct BindingFileSnapshot {
128
325
  path: PathBuf,
129
326
  bytes: Option<Vec<u8>>,
@@ -153,6 +350,7 @@ impl BindingFileSnapshot {
153
350
  }
154
351
  }
155
352
  }
353
+
156
354
  }
157
355
 
158
356
  fn binding_snapshots(workspace: &Path, team_key: &str) -> Vec<BindingFileSnapshot> {
@@ -242,45 +440,82 @@ fn persisted_binding_matches_verified_pane(
242
440
  fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
243
441
  workspace: &Path,
244
442
  team_key: &str,
443
+ seeded_owner: Option<&serde_json::Value>,
245
444
  ops: &mut O,
246
- ) -> bool {
445
+ ) -> Result<bool, LifecycleError> {
247
446
  let Ok(resolved) = crate::state::projection::resolve_runtime_team_scope(
248
447
  workspace,
249
448
  Some(team_key),
250
449
  ) else {
251
- return false;
450
+ return refuse_fresh_bind(workspace, team_key, "scope", "scope_unresolved");
252
451
  };
253
452
  if resolved.canonical_team_key != team_key {
254
- return false;
453
+ return refuse_fresh_bind(workspace, team_key, "scope", "scope_key_mismatch");
255
454
  }
256
455
  let mut state = resolved.state;
257
456
  let Some(pane) = ops.caller_pane().filter(|pane| !pane.is_empty()) else {
258
- return false;
457
+ return refuse_fresh_bind(workspace, team_key, "caller_pane", "caller_pane_missing");
259
458
  };
260
459
  let pane = PaneId::new(pane);
261
460
  let Some(command) = ops
262
461
  .observe_command(&pane)
263
462
  .filter(|command| !command.trim().is_empty())
264
463
  else {
265
- return false;
464
+ return refuse_fresh_bind(
465
+ workspace,
466
+ team_key,
467
+ "command_observation",
468
+ "command_unobservable",
469
+ );
266
470
  };
267
- let explicit_provider = ops.explicit_provider();
268
- let Some(provider) = crate::leader::owner_bind::strict_owner_bind_provider(
269
- explicit_provider.as_deref(),
270
- &command,
271
- ) else {
272
- return 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
+ }
273
497
  };
274
- if ops.live_binding_elsewhere(&pane, workspace, team_key) {
275
- return false;
276
- }
277
498
  let endpoint = ops.tmux_endpoint();
278
- // initial_runtime_state seeds the verified caller into the launched team's
279
- // dual state before spawn. That is the transaction's candidate, not an
280
- // already-owned-team refusal: it still needs the canonical registry commit.
281
499
  let has_persisted_binding = ["team_owner", "leader_receiver"]
282
500
  .iter()
283
501
  .any(|key| state.get(*key).is_some_and(|value| !value.is_null()));
502
+ // `claimed_via` is historical state, not proof that this invocation seeded
503
+ // the owner. Cleanup is allowed only when the caller supplies the exact
504
+ // in-memory seed created immediately before this bind attempt and the
505
+ // projected state still contains that seed.
506
+ let fresh_seeded_binding = seeded_owner.is_some_and(|seed| {
507
+ state
508
+ .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()
513
+ } else {
514
+ None
515
+ };
516
+ // Same-Team owner collision remains fail-closed. Cross-Team registry rows
517
+ // are intentionally irrelevant: pane ids are scoped to their tmux server,
518
+ // and independent endpoints may reuse the same numeric id.
284
519
  if has_persisted_binding
285
520
  && !persisted_binding_matches_verified_pane(
286
521
  &state,
@@ -290,21 +525,123 @@ fn bind_fresh_quick_start_leader_with<O: FreshQuickStartLeaderBindingOps>(
290
525
  endpoint.as_deref(),
291
526
  )
292
527
  {
293
- return false;
528
+ return refuse_fresh_bind(
529
+ workspace,
530
+ team_key,
531
+ "persisted_binding",
532
+ "persisted_binding_mismatch",
533
+ );
294
534
  }
295
- // Registry publication is the commit point. Restore both persisted surfaces
296
- // byte for byte when attach, publication, or canonical readback does not finish.
535
+ // Registry publication is the commit point. Restore persisted surfaces on
536
+ // failure, except for the caller-seeded owner that must be cleared.
297
537
  let snapshots = binding_snapshots(workspace, team_key);
298
- let attached = has_persisted_binding || ops.attach(workspace, &mut state, &pane, provider);
299
- let committed = attached
300
- && ops.register(workspace, team_key)
301
- && 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
+ };
302
583
  if !committed {
303
- for snapshot in snapshots.iter().rev() {
304
- snapshot.restore();
584
+ if ops.attach_cas_conflict() {
585
+ return Ok(false);
305
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
604
+ .get("team_owner")
605
+ .is_some_and(|current| current == *seed)
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);
611
+ }
612
+ } 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
+ 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() {
620
+ snapshot.restore();
621
+ }
622
+ if let Some(error) = registry_rollback_error {
623
+ return Err(error);
624
+ }
625
+ }
626
+ }
627
+ Ok(committed)
628
+ }
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
+ ))),
306
644
  }
307
- committed
308
645
  }
309
646
 
310
647
  fn should_emit_workspace_socket_missing_hint(
@@ -314,15 +651,64 @@ fn should_emit_workspace_socket_missing_hint(
314
651
  workspace_socket_missing && selected_source != Some("leader_env")
315
652
  }
316
653
 
654
+ fn clear_fresh_binding_on_refusal(
655
+ workspace: &Path,
656
+ state: &mut serde_json::Value,
657
+ team_key: &str,
658
+ seed: &serde_json::Value,
659
+ expected_grant: Option<(&serde_json::Value, &serde_json::Value)>,
660
+ ) -> Result<(), LifecycleError> {
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.
664
+ if let Some(obj) = state.as_object_mut() {
665
+ obj.remove("leader_receiver");
666
+ obj.remove("team_owner");
667
+ obj.remove("owner_epoch");
668
+ }
669
+ if let Some(team) = state
670
+ .get_mut("teams")
671
+ .and_then(serde_json::Value::as_object_mut)
672
+ .and_then(|teams| teams.get_mut(team_key))
673
+ .and_then(serde_json::Value::as_object_mut)
674
+ {
675
+ team.insert("leader_receiver".to_string(), serde_json::Value::Null);
676
+ team.insert("team_owner".to_string(), serde_json::Value::Null);
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
+ };
687
+ crate::state::repository::StateRepository::new(workspace)
688
+ .save(intent, state)
689
+ .map_err(|error| LifecycleError::StatePersist(format!(
690
+ "quick-start binding cleanup failed: {error}"
691
+ )))
692
+ }
693
+
317
694
  fn bind_fresh_quick_start_leader(
318
695
  workspace: &Path,
319
696
  team_key: &str,
697
+ seeded_owner: Option<&serde_json::Value>,
320
698
  transport: &dyn Transport,
321
- ) -> bool {
699
+ ) -> Result<bool, LifecycleError> {
322
700
  bind_fresh_quick_start_leader_with(
323
701
  workspace,
324
702
  team_key,
325
- &mut RuntimeFreshQuickStartLeaderBindingOps { transport },
703
+ seeded_owner,
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
+ },
326
712
  )
327
713
  }
328
714
 
@@ -735,13 +1121,29 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
735
1121
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
736
1122
  let resolved_spec_path =
737
1123
  std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
1124
+ let scoped_endpoint = transport.tmux_endpoint();
738
1125
  let mut state = initial_runtime_state(
739
1126
  &spec,
740
1127
  &resolved_spec_path,
741
1128
  &workspace,
742
1129
  agents_dir,
743
1130
  &state_team_key,
1131
+ scoped_endpoint.as_deref(),
744
1132
  );
1133
+ // Keep this attempt-local seed separate from persisted `claimed_via`;
1134
+ // historical quick-start rows are never sufficient cleanup authority.
1135
+ let seeded_owner = state
1136
+ .get("teams")
1137
+ .and_then(serde_json::Value::as_object)
1138
+ .and_then(|teams| teams.get(&state_team_key))
1139
+ .and_then(|team| team.get("team_owner"))
1140
+ .filter(|owner| {
1141
+ owner
1142
+ .get("claimed_via")
1143
+ .and_then(serde_json::Value::as_str)
1144
+ == Some("quick-start")
1145
+ })
1146
+ .cloned();
745
1147
  // 0.5.x Phase 1d hot-path 接线(裁决1 msg_76e1d98202b8): use the
746
1148
  // generic annotator that writes `state.transport = { kind, source }`
747
1149
  // for every backend AND (for tmux) preserves the existing
@@ -778,8 +1180,12 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
778
1180
  // Fresh initialization owns this one fail-closed bind attempt. It is
779
1181
  // independent of display layout, so --no-display never suppresses receiver
780
1182
  // binding. Readiness receives true only after canonical registry readback.
781
- launch.leader_receiver_attached =
782
- bind_fresh_quick_start_leader(&workspace, &state_team_key, transport);
1183
+ launch.leader_receiver_attached = bind_fresh_quick_start_leader(
1184
+ &workspace,
1185
+ &state_team_key,
1186
+ seeded_owner.as_ref(),
1187
+ transport,
1188
+ )?;
783
1189
  launch.session_capture_incomplete_agents =
784
1190
  quick_start_session_capture_incomplete_agents(&workspace, &state_team_key);
785
1191
  let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
@@ -858,9 +1264,15 @@ pub(crate) fn quick_start_with_transport_in_workspace_with_display_pi_preflight(
858
1264
  #[cfg(test)]
859
1265
  mod fresh_quick_start_leader_binding_tests {
860
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};
861
1270
  use serde_json::json;
1271
+ use std::collections::BTreeMap;
862
1272
  use std::sync::atomic::{AtomicU64, Ordering};
863
1273
 
1274
+ use super::hermetic::HermeticTestEnv;
1275
+
864
1276
  static SEQUENCE: AtomicU64 = AtomicU64::new(0);
865
1277
 
866
1278
  fn workspace(tag: &str) -> PathBuf {
@@ -888,7 +1300,6 @@ mod fresh_quick_start_leader_binding_tests {
888
1300
  explicit_provider: Option<String>,
889
1301
  endpoint: Option<String>,
890
1302
  command: Option<String>,
891
- live_elsewhere: bool,
892
1303
  attach_ok: bool,
893
1304
  register_ok: bool,
894
1305
  readback_ok: bool,
@@ -896,6 +1307,7 @@ mod fresh_quick_start_leader_binding_tests {
896
1307
  register_calls: usize,
897
1308
  readback_calls: usize,
898
1309
  attached_provider: Option<crate::provider::Provider>,
1310
+ applied_grant: Option<(serde_json::Value, serde_json::Value)>,
899
1311
  }
900
1312
 
901
1313
  impl Default for MockOps {
@@ -905,7 +1317,6 @@ mod fresh_quick_start_leader_binding_tests {
905
1317
  explicit_provider: Some("pi".to_string()),
906
1318
  endpoint: Some("/private/tmp/tmux-test/default".to_string()),
907
1319
  command: Some("pi".to_string()),
908
- live_elsewhere: false,
909
1320
  attach_ok: true,
910
1321
  register_ok: true,
911
1322
  readback_ok: true,
@@ -913,10 +1324,58 @@ mod fresh_quick_start_leader_binding_tests {
913
1324
  register_calls: 0,
914
1325
  readback_calls: 0,
915
1326
  attached_provider: None,
1327
+ applied_grant: None,
916
1328
  }
917
1329
  }
918
1330
  }
919
1331
 
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);
1377
+ }
1378
+
920
1379
  impl FreshQuickStartLeaderBindingOps for MockOps {
921
1380
  fn caller_pane(&mut self) -> Option<String> {
922
1381
  self.pane.clone()
@@ -934,15 +1393,6 @@ mod fresh_quick_start_leader_binding_tests {
934
1393
  self.command.clone()
935
1394
  }
936
1395
 
937
- fn live_binding_elsewhere(
938
- &mut self,
939
- _pane: &PaneId,
940
- _workspace: &Path,
941
- _team_key: &str,
942
- ) -> bool {
943
- self.live_elsewhere
944
- }
945
-
946
1396
  fn attach(
947
1397
  &mut self,
948
1398
  workspace: &Path,
@@ -950,6 +1400,7 @@ mod fresh_quick_start_leader_binding_tests {
950
1400
  pane: &PaneId,
951
1401
  provider: crate::provider::Provider,
952
1402
  ) -> bool {
1403
+ self.applied_grant = None;
953
1404
  self.attach_calls += 1;
954
1405
  self.attached_provider = Some(provider);
955
1406
  if !self.attach_ok {
@@ -957,30 +1408,79 @@ mod fresh_quick_start_leader_binding_tests {
957
1408
  let _ = crate::state::persist::save_runtime_state(workspace, state);
958
1409
  return false;
959
1410
  }
960
- 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!({
961
1431
  "mode": "direct_tmux",
962
1432
  "pane_id": pane.as_str(),
963
1433
  "status": "attached",
964
1434
  "provider": "pi",
965
1435
  "leader_session_uuid": "uuid-fresh",
966
- "owner_epoch": 1,
1436
+ "owner_epoch": owner_epoch,
967
1437
  "tmux_socket": self.endpoint
968
1438
  });
969
- state["team_owner"] = json!({
970
- "pane_id": pane.as_str(),
971
- "provider": "pi",
972
- "leader_session_uuid": "uuid-fresh",
973
- "owner_epoch": 1
974
- });
975
- 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
+ }
976
1468
  }
977
1469
 
978
- fn register(&mut self, workspace: &Path, _team_key: &str) -> bool {
1470
+ fn applied_grant(&self) -> Option<(serde_json::Value, serde_json::Value)> {
1471
+ self.applied_grant.clone()
1472
+ }
1473
+
1474
+ fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
979
1475
  self.register_calls += 1;
980
1476
  let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
981
1477
  assert_eq!(
982
1478
  persisted
983
- .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"))
984
1484
  .and_then(serde_json::Value::as_str),
985
1485
  Some("%42")
986
1486
  );
@@ -991,7 +1491,7 @@ mod fresh_quick_start_leader_binding_tests {
991
1491
  self.register_ok
992
1492
  }
993
1493
 
994
- fn canonical_readback(&mut self, workspace: &Path, _team_key: &str) -> bool {
1494
+ fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
995
1495
  self.readback_calls += 1;
996
1496
  let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
997
1497
  if !self.readback_ok {
@@ -1000,7 +1500,10 @@ mod fresh_quick_start_leader_binding_tests {
1000
1500
  }
1001
1501
  self.readback_ok
1002
1502
  && persisted
1003
- .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"))
1004
1507
  .is_some_and(|receiver| !receiver.is_null())
1005
1508
  }
1006
1509
  }
@@ -1009,11 +1512,8 @@ mod fresh_quick_start_leader_binding_tests {
1009
1512
  fn fresh_binding_persists_then_registers_then_requires_canonical_readback() {
1010
1513
  let workspace = workspace("positive");
1011
1514
  let mut ops = MockOps::default();
1012
- assert!(bind_fresh_quick_start_leader_with(
1013
- &workspace,
1014
- "fresh",
1015
- &mut ops
1016
- ));
1515
+ assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
1516
+ .unwrap());
1017
1517
  assert_eq!(ops.attached_provider, Some(crate::provider::Provider::Pi));
1018
1518
  assert_eq!(ops.attach_calls, 1);
1019
1519
  assert_eq!(ops.register_calls, 1);
@@ -1021,10 +1521,110 @@ mod fresh_quick_start_leader_binding_tests {
1021
1521
  let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
1022
1522
  assert_eq!(
1023
1523
  state
1024
- .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"))
1025
1529
  .and_then(serde_json::Value::as_str),
1026
1530
  Some("%42")
1027
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"));
1028
1628
  }
1029
1629
 
1030
1630
  #[test]
@@ -1042,7 +1642,6 @@ mod fresh_quick_start_leader_binding_tests {
1042
1642
  "socket_mismatch",
1043
1643
  "workspace_mismatch",
1044
1644
  "dual_state_mismatch",
1045
- "live_other_scope",
1046
1645
  ] {
1047
1646
  let workspace = workspace(case);
1048
1647
  let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
@@ -1088,7 +1687,6 @@ mod fresh_quick_start_leader_binding_tests {
1088
1687
  _ => unreachable!(),
1089
1688
  }
1090
1689
  }
1091
- "live_other_scope" => ops.live_elsewhere = true,
1092
1690
  "team_mismatch" => {}
1093
1691
  _ => unreachable!(),
1094
1692
  }
@@ -1096,7 +1694,8 @@ mod fresh_quick_start_leader_binding_tests {
1096
1694
  let before = std::fs::read(crate::state::persist::runtime_state_path(&workspace))
1097
1695
  .unwrap();
1098
1696
  assert!(
1099
- !bind_fresh_quick_start_leader_with(&workspace, team_key, &mut ops),
1697
+ !bind_fresh_quick_start_leader_with(&workspace, team_key, None, &mut ops)
1698
+ .unwrap(),
1100
1699
  "{case} must refuse"
1101
1700
  );
1102
1701
  assert_eq!(
@@ -1179,6 +1778,7 @@ mod fresh_quick_start_leader_binding_tests {
1179
1778
  struct AmbientRegistryOps {
1180
1779
  readback_ok: bool,
1181
1780
  attach_calls: usize,
1781
+ registry_receipt: Option<crate::leader::registry::RegistryWriteReceipt>,
1182
1782
  }
1183
1783
 
1184
1784
  impl FreshQuickStartLeaderBindingOps for AmbientRegistryOps {
@@ -1198,15 +1798,6 @@ mod fresh_quick_start_leader_binding_tests {
1198
1798
  Some("pi".to_string())
1199
1799
  }
1200
1800
 
1201
- fn live_binding_elsewhere(
1202
- &mut self,
1203
- _pane: &PaneId,
1204
- _workspace: &Path,
1205
- _team_key: &str,
1206
- ) -> bool {
1207
- false
1208
- }
1209
-
1210
1801
  fn attach(
1211
1802
  &mut self,
1212
1803
  _workspace: &Path,
@@ -1219,12 +1810,19 @@ mod fresh_quick_start_leader_binding_tests {
1219
1810
  }
1220
1811
 
1221
1812
  fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
1222
- crate::leader::registry::register_binding_from_state_best_effort(
1813
+ let Some(outcome) = crate::leader::registry::register_binding_from_state_best_effort(
1223
1814
  workspace,
1224
1815
  Some(team_key),
1225
1816
  "quick-start",
1226
- )
1227
- .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()
1228
1826
  }
1229
1827
 
1230
1828
  fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
@@ -1245,12 +1843,10 @@ mod fresh_quick_start_leader_binding_tests {
1245
1843
  let mut ops = AmbientRegistryOps {
1246
1844
  readback_ok: true,
1247
1845
  attach_calls: 0,
1846
+ registry_receipt: None,
1248
1847
  };
1249
- assert!(bind_fresh_quick_start_leader_with(
1250
- &workspace,
1251
- "fresh",
1252
- &mut ops
1253
- ));
1848
+ assert!(bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
1849
+ .unwrap());
1254
1850
  assert_eq!(ops.attach_calls, 0, "preseeded dual state must not reattach");
1255
1851
  let path = crate::leader::registry::registry_dir().unwrap().join(format!(
1256
1852
  "{}__fresh.json",
@@ -1265,38 +1861,359 @@ mod fresh_quick_start_leader_binding_tests {
1265
1861
  }
1266
1862
 
1267
1863
  #[test]
1268
- fn attach_registry_or_readback_failure_rolls_back_exact_state_bytes() {
1269
- for (case, mut ops) in [
1270
- (
1271
- "attach-failure",
1272
- MockOps {
1273
- attach_ok: false,
1274
- ..MockOps::default()
1275
- },
1276
- ),
1277
- (
1278
- "registry-failure",
1279
- MockOps {
1280
- register_ok: false,
1281
- ..MockOps::default()
1282
- },
1283
- ),
1284
- (
1285
- "readback-failure",
1286
- MockOps {
1287
- readback_ok: false,
1288
- ..MockOps::default()
1289
- },
1864
+ fn historical_quick_start_owner_is_not_cleanup_authority() {
1865
+ let workspace = workspace("historical-quick-start-owner");
1866
+ let mut state = crate::state::persist::load_runtime_state(&workspace).unwrap();
1867
+ state["workspace"] = json!(workspace);
1868
+ state["team_owner"] = json!({
1869
+ "pane_id": "%old",
1870
+ "provider": "pi",
1871
+ "leader_session_uuid": "uuid-old",
1872
+ "owner_epoch": 4,
1873
+ "claimed_via": "quick-start"
1874
+ });
1875
+ state["leader_receiver"] = json!({
1876
+ "mode": "direct_tmux",
1877
+ "status": "attached",
1878
+ "pane_id": "%old",
1879
+ "provider": "pi",
1880
+ "leader_session_uuid": "uuid-old",
1881
+ "owner_epoch": 4,
1882
+ "tmux_socket": "/private/tmp/tmux-test/old"
1883
+ });
1884
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
1885
+ let before = std::fs::read(crate::state::persist::runtime_state_path(&workspace)).unwrap();
1886
+ let mut ops = MockOps::default();
1887
+
1888
+ assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
1889
+ .unwrap());
1890
+ assert_eq!(
1891
+ before,
1892
+ std::fs::read(crate::state::persist::runtime_state_path(&workspace)).unwrap(),
1893
+ "historical claimed_via alone must not authorize cleanup"
1894
+ );
1895
+ }
1896
+
1897
+ fn matching_seed() -> serde_json::Value {
1898
+ json!({
1899
+ "pane_id": "%42",
1900
+ "provider": "pi",
1901
+ "leader_session_uuid": "uuid-fresh",
1902
+ "owner_epoch": 1,
1903
+ "claimed_via": "quick-start"
1904
+ })
1905
+ }
1906
+
1907
+ fn matching_receiver() -> serde_json::Value {
1908
+ json!({
1909
+ "mode": "direct_tmux",
1910
+ "status": "attached",
1911
+ "pane_id": "%42",
1912
+ "provider": "pi",
1913
+ "leader_session_uuid": "uuid-fresh",
1914
+ "owner_epoch": 1,
1915
+ "tmux_socket": "/private/tmp/tmux-test/default"
1916
+ })
1917
+ }
1918
+
1919
+ fn workspace_with_matching_seed(tag: &str) -> (PathBuf, serde_json::Value) {
1920
+ let workspace = workspace(tag);
1921
+ let seed = matching_seed();
1922
+ crate::state::persist::save_runtime_state(
1923
+ &workspace,
1924
+ &json!({
1925
+ "workspace": workspace,
1926
+ "active_team_key": "fresh",
1927
+ "team_key": "fresh",
1928
+ "session_name": "team-fresh",
1929
+ "agents": {"sol": {"status": "running", "provider": "pi"}},
1930
+ "teams": {
1931
+ "fresh": {
1932
+ "workspace": workspace,
1933
+ "team_key": "fresh",
1934
+ "session_name": "team-fresh",
1935
+ "agents": {"sol": {"status": "running", "provider": "pi"}},
1936
+ "team_owner": seed,
1937
+ "leader_receiver": matching_receiver(),
1938
+ "owner_epoch": 1
1939
+ }
1940
+ }
1941
+ }),
1942
+ )
1943
+ .unwrap();
1944
+ let seed = crate::state::persist::load_runtime_state(&workspace).unwrap()["teams"]["fresh"]
1945
+ ["team_owner"]
1946
+ .clone();
1947
+ (workspace, seed)
1948
+ }
1949
+
1950
+ struct ConcurrentTakeover {
1951
+ owner: serde_json::Value,
1952
+ receiver: serde_json::Value,
1953
+ epoch: u64,
1954
+ }
1955
+
1956
+ fn takeover_binding(epoch: u64) -> ConcurrentTakeover {
1957
+ ConcurrentTakeover {
1958
+ owner: json!({
1959
+ "pane_id": "%99",
1960
+ "provider": "codex",
1961
+ "leader_session_uuid": "uuid-other",
1962
+ "owner_epoch": epoch,
1963
+ "claimed_via": "claim-leader"
1964
+ }),
1965
+ receiver: json!({
1966
+ "mode": "direct_tmux",
1967
+ "status": "attached",
1968
+ "pane_id": "%99",
1969
+ "provider": "codex",
1970
+ "leader_session_uuid": "uuid-other",
1971
+ "owner_epoch": epoch,
1972
+ "tmux_socket": "/private/tmp/tmux-test/other"
1973
+ }),
1974
+ epoch,
1975
+ }
1976
+ }
1977
+
1978
+ struct SeedCleanupOps {
1979
+ inner: MockOps,
1980
+ takeover: Option<ConcurrentTakeover>,
1981
+ poison_state_file: bool,
1982
+ }
1983
+
1984
+ impl FreshQuickStartLeaderBindingOps for SeedCleanupOps {
1985
+ fn caller_pane(&mut self) -> Option<String> {
1986
+ self.inner.caller_pane()
1987
+ }
1988
+
1989
+ fn explicit_provider(&mut self) -> Option<String> {
1990
+ self.inner.explicit_provider()
1991
+ }
1992
+
1993
+ fn tmux_endpoint(&mut self) -> Option<String> {
1994
+ self.inner.tmux_endpoint()
1995
+ }
1996
+
1997
+ fn observe_command(&mut self, pane: &PaneId) -> Option<String> {
1998
+ self.inner.observe_command(pane)
1999
+ }
2000
+
2001
+ fn attach(
2002
+ &mut self,
2003
+ workspace: &Path,
2004
+ state: &mut serde_json::Value,
2005
+ pane: &PaneId,
2006
+ provider: crate::provider::Provider,
2007
+ ) -> bool {
2008
+ self.inner.attach(workspace, state, pane, provider)
2009
+ }
2010
+
2011
+ fn applied_grant(&self) -> Option<(serde_json::Value, serde_json::Value)> {
2012
+ self.inner.applied_grant()
2013
+ }
2014
+
2015
+ fn register(&mut self, workspace: &Path, team_key: &str) -> bool {
2016
+ self.inner.register_calls += 1;
2017
+ if let Some(takeover) = &self.takeover {
2018
+ let mut persisted = crate::state::persist::load_runtime_state(workspace).unwrap();
2019
+ persisted["teams"][team_key]["team_owner"] = takeover.owner.clone();
2020
+ persisted["teams"][team_key]["leader_receiver"] = takeover.receiver.clone();
2021
+ persisted["teams"][team_key]["owner_epoch"] = json!(takeover.epoch);
2022
+ crate::state::persist::save_runtime_state_with_receiver_authority(
2023
+ workspace,
2024
+ &persisted,
2025
+ team_key,
2026
+ None,
2027
+ )
2028
+ .unwrap();
2029
+ assert_fresh_binding_kept(
2030
+ &crate::state::persist::load_runtime_state(workspace).unwrap(),
2031
+ takeover,
2032
+ );
2033
+ }
2034
+ if self.poison_state_file {
2035
+ let path = crate::state::persist::runtime_state_path(workspace);
2036
+ let _ = std::fs::remove_file(&path);
2037
+ std::fs::create_dir_all(&path).unwrap();
2038
+ }
2039
+ false
2040
+ }
2041
+
2042
+ fn canonical_readback(&mut self, workspace: &Path, team_key: &str) -> bool {
2043
+ self.inner.canonical_readback(workspace, team_key)
2044
+ }
2045
+ }
2046
+
2047
+ fn failed_register_ops(takeover: Option<ConcurrentTakeover>, poison: bool) -> SeedCleanupOps {
2048
+ SeedCleanupOps {
2049
+ inner: MockOps {
2050
+ register_ok: false,
2051
+ ..MockOps::default()
2052
+ },
2053
+ takeover,
2054
+ poison_state_file: poison,
2055
+ }
2056
+ }
2057
+
2058
+ fn assert_fresh_binding_kept(
2059
+ persisted: &serde_json::Value,
2060
+ takeover: &ConcurrentTakeover,
2061
+ ) {
2062
+ assert_eq!(
2063
+ persisted["teams"]["fresh"]["team_owner"],
2064
+ takeover.owner,
2065
+ "concurrent team_owner must survive seeded cleanup"
2066
+ );
2067
+ assert_eq!(
2068
+ persisted["teams"]["fresh"]["leader_receiver"],
2069
+ takeover.receiver,
2070
+ "concurrent leader_receiver must survive seeded cleanup"
2071
+ );
2072
+ assert_eq!(
2073
+ persisted["teams"]["fresh"]["owner_epoch"],
2074
+ json!(takeover.epoch),
2075
+ "concurrent owner_epoch must survive seeded cleanup"
2076
+ );
2077
+ }
2078
+
2079
+ #[test]
2080
+ fn seeded_cleanup_clears_exact_seed_through_bind_repository_entry() {
2081
+ let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-success");
2082
+ let mut ops = failed_register_ops(None, false);
2083
+ assert!(!bind_fresh_quick_start_leader_with(
2084
+ &workspace,
2085
+ "fresh",
2086
+ Some(&seed),
2087
+ &mut ops
2088
+ )
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
+ }));
2099
+ let persisted = crate::state::persist::load_runtime_state(&workspace).unwrap();
2100
+ assert!(
2101
+ persisted
2102
+ .pointer("/teams/fresh/team_owner")
2103
+ .is_some_and(serde_json::Value::is_null),
2104
+ "matching seed must be cleared via bind/repository; got {}",
2105
+ persisted["teams"]["fresh"]["team_owner"]
2106
+ );
2107
+ assert!(
2108
+ persisted
2109
+ .pointer("/teams/fresh/leader_receiver")
2110
+ .is_some_and(serde_json::Value::is_null)
2111
+ );
2112
+ assert_eq!(
2113
+ persisted["teams"]["fresh"]["owner_epoch"],
2114
+ json!(1),
2115
+ "seed cleanup must not bump owner_epoch"
2116
+ );
2117
+ }
2118
+
2119
+ #[test]
2120
+ fn seeded_cleanup_preserves_equal_epoch_persisted_concurrent_owner() {
2121
+ let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-concurrent");
2122
+ let takeover = takeover_binding(1);
2123
+ let mut ops = failed_register_ops(Some(takeover_binding(1)), false);
2124
+ assert!(!bind_fresh_quick_start_leader_with(
2125
+ &workspace,
2126
+ "fresh",
2127
+ Some(&seed),
2128
+ &mut ops
2129
+ )
2130
+ .unwrap());
2131
+ assert_fresh_binding_kept(
2132
+ &crate::state::persist::load_runtime_state(&workspace).unwrap(),
2133
+ &takeover,
2134
+ );
2135
+ }
2136
+
2137
+ #[test]
2138
+ fn seeded_cleanup_preserves_epoch2_takeover_owner_receiver_epoch() {
2139
+ let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-epoch2");
2140
+ let takeover = takeover_binding(2);
2141
+ let mut ops = failed_register_ops(Some(takeover_binding(2)), false);
2142
+ assert!(!bind_fresh_quick_start_leader_with(
2143
+ &workspace,
2144
+ "fresh",
2145
+ Some(&seed),
2146
+ &mut ops
2147
+ )
2148
+ .unwrap());
2149
+ assert_fresh_binding_kept(
2150
+ &crate::state::persist::load_runtime_state(&workspace).unwrap(),
2151
+ &takeover,
2152
+ );
2153
+ }
2154
+
2155
+ #[test]
2156
+ fn seeded_cleanup_propagates_persist_error() {
2157
+ let (workspace, seed) = workspace_with_matching_seed("seed-cleanup-persist-err");
2158
+ let mut ops = failed_register_ops(None, true);
2159
+ let error = bind_fresh_quick_start_leader_with(
2160
+ &workspace,
2161
+ "fresh",
2162
+ Some(&seed),
2163
+ &mut ops,
2164
+ )
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
+ }));
2175
+ match error {
2176
+ LifecycleError::StatePersist(text) => {
2177
+ assert!(
2178
+ text.contains("quick-start binding cleanup failed"),
2179
+ "unexpected persist error: {text}"
2180
+ );
2181
+ }
2182
+ other => panic!("expected StatePersist, got {other:?}"),
2183
+ }
2184
+ let _ = std::fs::remove_dir_all(crate::state::persist::runtime_state_path(&workspace));
2185
+ }
2186
+
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
+ },
1290
2210
  ),
1291
2211
  ] {
1292
2212
  let workspace = workspace(case);
1293
2213
  let state_path = crate::state::persist::runtime_state_path(&workspace);
1294
2214
  let before = std::fs::read(&state_path).unwrap();
1295
- assert!(!bind_fresh_quick_start_leader_with(
1296
- &workspace,
1297
- "fresh",
1298
- &mut ops
1299
- ));
2215
+ assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
2216
+ .unwrap());
1300
2217
  assert_eq!(
1301
2218
  std::fs::read(&state_path).unwrap(),
1302
2219
  before,
@@ -1356,13 +2273,1240 @@ mod fresh_quick_start_leader_binding_tests {
1356
2273
  let mut ops = AmbientRegistryOps {
1357
2274
  readback_ok: false,
1358
2275
  attach_calls: 0,
2276
+ registry_receipt: None,
1359
2277
  };
1360
- assert!(!bind_fresh_quick_start_leader_with(
2278
+ assert!(!bind_fresh_quick_start_leader_with(&workspace, "fresh", None, &mut ops)
2279
+ .unwrap());
2280
+ assert_eq!(std::fs::read(state_path).unwrap(), before);
2281
+ assert!(!registry_path.exists(), "failed readback left registry bytes");
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(
1361
2478
  &workspace,
1362
2479
  "fresh",
2480
+ Some(&seed),
1363
2481
  &mut ops
1364
- ));
1365
- assert_eq!(std::fs::read(state_path).unwrap(), before);
1366
- assert!(!registry_path.exists(), "failed readback left registry bytes");
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
+ );
1367
3511
  }
1368
3512
  }