@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
@@ -26,6 +26,41 @@ use super::{
26
26
 
27
27
  // ── leader::lease — attach / claim / takeover / autobind / readopt 统一 CAS 路径 ──
28
28
 
29
+ #[derive(Debug, Clone)]
30
+ pub(crate) struct AppliedLeaderGrant {
31
+ pub(crate) owner: Value,
32
+ pub(crate) receiver: Value,
33
+ }
34
+
35
+ #[derive(Debug)]
36
+ pub(crate) struct AttachLeaderError {
37
+ source: LeaderError,
38
+ applied_grant: Option<AppliedLeaderGrant>,
39
+ }
40
+
41
+ impl AttachLeaderError {
42
+ fn with_applied(source: LeaderError, applied_grant: Option<AppliedLeaderGrant>) -> Self {
43
+ Self {
44
+ source,
45
+ applied_grant,
46
+ }
47
+ }
48
+
49
+ pub(crate) fn into_parts(self) -> (LeaderError, Option<AppliedLeaderGrant>) {
50
+ (self.source, self.applied_grant)
51
+ }
52
+
53
+ fn into_error(self) -> LeaderError {
54
+ self.source
55
+ }
56
+ }
57
+
58
+ impl From<LeaderError> for AttachLeaderError {
59
+ fn from(source: LeaderError) -> Self {
60
+ Self::with_applied(source, None)
61
+ }
62
+ }
63
+
29
64
  /// `attach_leader`(card §42;`__init__.py:19`)。手动 CLI attach;持 `LEADER_OWNERSHIP_LOCK`
30
65
  /// 整段临界区做 state 变更 + 事件 + 双写 + requeue exhausted watchers。
31
66
  pub fn attach_leader(
@@ -288,6 +323,23 @@ struct AttachLeaderTarget {
288
323
 
289
324
  fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTarget> {
290
325
  let mut targets = Vec::new();
326
+ // The caller's verified tmux server is authoritative for discovering its
327
+ // leader pane. Keep state-recorded worker endpoints as additional targets,
328
+ // but do not let a cross-socket pane-id collision hide the caller pane.
329
+ if let Some(endpoint) = crate::tmux_backend::socket_name_from_tmux_env() {
330
+ let backend = tmux_backend_for_endpoint(&endpoint);
331
+ let resolved_endpoint = backend.tmux_endpoint();
332
+ targets.extend(
333
+ backend
334
+ .list_targets()
335
+ .unwrap_or_default()
336
+ .into_iter()
337
+ .map(|info| AttachLeaderTarget {
338
+ info,
339
+ endpoint: resolved_endpoint.clone(),
340
+ }),
341
+ );
342
+ }
291
343
  for endpoint in state_recorded_tmux_endpoints(state) {
292
344
  let backend = tmux_backend_for_endpoint(&endpoint);
293
345
  let resolved_endpoint = backend.tmux_endpoint();
@@ -437,63 +489,140 @@ pub(crate) fn attach_leader_to_state(
437
489
  source: LeaseSource,
438
490
  require_current: bool,
439
491
  ) -> Result<(LeaderReceiver, Value), LeaderError> {
440
- let _ = (source, require_current);
492
+ attach_leader_to_state_with_target_and_controls(
493
+ workspace,
494
+ state,
495
+ pane,
496
+ provider,
497
+ event_log,
498
+ source,
499
+ require_current,
500
+ None,
501
+ None,
502
+ None,
503
+ None,
504
+ )
505
+ .map_err(AttachLeaderError::into_error)
506
+ }
507
+
508
+ #[allow(clippy::too_many_arguments)]
509
+ pub(crate) fn attach_leader_to_state_with_target(
510
+ workspace: &Path,
511
+ state: &mut Value,
512
+ pane: Option<&PaneId>,
513
+ provider: Provider,
514
+ event_log: &crate::event_log::EventLog,
515
+ source: LeaseSource,
516
+ require_current: bool,
517
+ caller_target: Option<&PaneInfo>,
518
+ ) -> Result<(LeaderReceiver, Value), LeaderError> {
519
+ attach_leader_to_state_with_target_and_controls(
520
+ workspace,
521
+ state,
522
+ pane,
523
+ provider,
524
+ event_log,
525
+ source,
526
+ require_current,
527
+ caller_target,
528
+ None,
529
+ None,
530
+ None,
531
+ )
532
+ .map_err(AttachLeaderError::into_error)
533
+ }
534
+
535
+ #[allow(clippy::too_many_arguments)]
536
+ pub(crate) fn attach_leader_to_state_with_target_and_controls(
537
+ workspace: &Path,
538
+ state: &mut Value,
539
+ pane: Option<&PaneId>,
540
+ provider: Provider,
541
+ event_log: &crate::event_log::EventLog,
542
+ source: LeaseSource,
543
+ require_current: bool,
544
+ caller_target: Option<&PaneInfo>,
545
+ expected_owner: Option<&Value>,
546
+ expected_receiver: Option<&Value>,
547
+ nonce_writer: Option<&dyn Fn(&str, &PaneId, &str) -> Result<String, LeaderError>>,
548
+ ) -> Result<(LeaderReceiver, Value), AttachLeaderError> {
549
+ if expected_owner.is_some() != expected_receiver.is_some() {
550
+ return Err(AttachLeaderError::from(LeaderError::Validation(
551
+ "fresh caller CAS requires both expected owner and receiver".to_string(),
552
+ )));
553
+ }
441
554
  let pane_id = pane
442
555
  .cloned()
443
556
  .ok_or_else(|| LeaderError::Validation("tmux pane not found".to_string()))?;
444
557
  let non_empty_pane_id = NonEmptyPaneId::try_from_pane(&pane_id)?;
445
558
  let identity = leader_identity_context(workspace, None, Some(state))?;
446
559
  let epoch = current_owner_epoch(state);
447
- let receiver = make_receiver(
560
+ let has_owner = state.get("team_owner").is_some();
561
+ let binding_epoch = if has_owner {
562
+ epoch
563
+ } else {
564
+ OwnerEpoch(epoch.0.saturating_add(1))
565
+ };
566
+ let mut receiver = make_receiver(
448
567
  provider,
449
568
  &non_empty_pane_id,
450
569
  &identity.leader_session_uuid,
451
- epoch,
570
+ binding_epoch,
452
571
  Discovery::EnvPane,
453
572
  None,
454
573
  );
455
- if state.get("team_owner").is_some() {
574
+ authorize_fresh_caller_receiver(
575
+ workspace,
576
+ &mut receiver,
577
+ &non_empty_pane_id,
578
+ source,
579
+ require_current,
580
+ caller_target,
581
+ nonce_writer,
582
+ )?;
583
+ let receiver_value = serde_json::to_value(&receiver).map_err(LeaderError::from)?;
584
+ if has_owner {
456
585
  write_receiver_to_state(state, &receiver)?;
457
586
  } else {
458
- let next_epoch = OwnerEpoch(epoch.0.saturating_add(1));
459
- let receiver = make_receiver(
460
- provider,
461
- &non_empty_pane_id,
462
- &identity.leader_session_uuid,
463
- next_epoch,
464
- Discovery::EnvPane,
465
- None,
466
- );
467
- let owner = make_owner(provider, &non_empty_pane_id, &identity, next_epoch);
587
+ let owner = make_owner(provider, &non_empty_pane_id, &identity, binding_epoch);
468
588
  write_binding_to_state(state, &receiver, &owner)?;
469
- write_lease_dual_state(workspace, state)?;
470
- event_log.write(
471
- super::LeaderEvent::ReceiverAttached.name(),
472
- json!({"pane_id": pane_id.as_str(), "owner_epoch": next_epoch.0}),
473
- )?;
474
- let team_id = TeamKey::new(crate::state::projection::team_state_key(state));
475
- crate::messaging::watchers::recover_watchers_for_incident(
589
+ }
590
+ if let (Some(expected_owner), Some(expected_receiver)) = (expected_owner, expected_receiver) {
591
+ write_lease_dual_state_with_expected(
476
592
  workspace,
477
- event_log,
478
- &team_id,
479
- &pane_id,
480
- LeaderIncident::LeaderAttached,
593
+ state,
594
+ expected_owner,
595
+ expected_receiver,
481
596
  )?;
482
- return Ok((receiver, json!({"ok": true})));
597
+ } else {
598
+ write_lease_dual_state(workspace, state)?;
483
599
  }
484
- write_lease_dual_state(workspace, state)?;
485
- event_log.write(
600
+ let applied_grant = state
601
+ .get("team_owner")
602
+ .cloned()
603
+ .map(|owner| AppliedLeaderGrant {
604
+ owner,
605
+ receiver: receiver_value,
606
+ });
607
+ if let Err(error) = event_log.write(
486
608
  super::LeaderEvent::ReceiverAttached.name(),
487
- json!({"pane_id": pane_id.as_str(), "owner_epoch": epoch.0}),
488
- )?;
609
+ json!({"pane_id": pane_id.as_str(), "owner_epoch": binding_epoch.0}),
610
+ ) {
611
+ return Err(AttachLeaderError::with_applied(
612
+ error.into(),
613
+ applied_grant.clone(),
614
+ ));
615
+ }
489
616
  let team_id = TeamKey::new(crate::state::projection::team_state_key(state));
490
- crate::messaging::watchers::recover_watchers_for_incident(
617
+ if let Err(error) = crate::messaging::watchers::recover_watchers_for_incident(
491
618
  workspace,
492
619
  event_log,
493
620
  &team_id,
494
621
  &pane_id,
495
622
  LeaderIncident::LeaderAttached,
496
- )?;
623
+ ) {
624
+ return Err(AttachLeaderError::with_applied(error.into(), applied_grant));
625
+ }
497
626
  Ok((receiver, json!({"ok": true})))
498
627
  }
499
628
 
@@ -554,7 +683,18 @@ pub fn claim_leader(
554
683
  "explicit cross-workspace claim requires an absolute tmux endpoint".to_string(),
555
684
  ));
556
685
  }
557
- let nonce = next_pane_binding_nonce(workspace, endpoint, &candidate.info.pane_id);
686
+ // A live pane nonce identifies the pane instance, not this team's
687
+ // claim. Preserve it so another child team remains authorized.
688
+ let nonce = pane_binding_nonce_for_claim(
689
+ workspace,
690
+ endpoint,
691
+ &candidate.info.pane_id,
692
+ candidate
693
+ .info
694
+ .leader_env
695
+ .get(crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY)
696
+ .map(String::as_str),
697
+ );
558
698
  target.scope_authority = Some("explicit_claim".to_string());
559
699
  target.authorized_team_workspace = Some(canonical_workspace(workspace));
560
700
  target.binding_nonce = Some(nonce);
@@ -600,7 +740,7 @@ pub fn claim_leader(
600
740
  confirm,
601
741
  &event_log,
602
742
  &liveness,
603
- caller_target.as_ref(),
743
+ caller_target.as_mut(),
604
744
  caller_pane_info,
605
745
  scoped_team.map(|_| team_id.as_str()),
606
746
  )?;
@@ -692,7 +832,7 @@ fn claim_lease_no_incident_with_target(
692
832
  confirm: bool,
693
833
  event_log: &crate::event_log::EventLog,
694
834
  liveness: &dyn crate::state::owner_gate::PaneLivenessProbe,
695
- caller_target: Option<&LeaderClaimTarget>,
835
+ mut caller_target: Option<&mut LeaderClaimTarget>,
696
836
  caller_pane_info: Option<&PaneInfo>,
697
837
  scoped_team: Option<&str>,
698
838
  ) -> Result<LeaseResult, LeaderError> {
@@ -734,9 +874,13 @@ fn claim_lease_no_incident_with_target(
734
874
  let non_empty_caller_pane = NonEmptyPaneId::try_from_pane(caller_pane)?;
735
875
  let bound_endpoint_matches_caller = bound_endpoint_matches_current_process(state);
736
876
  if bound_pane_id.as_deref() == Some(caller_pane.as_str()) && bound_endpoint_matches_caller {
737
- write_claim_target_pane_nonce(caller_target)?;
877
+ if let Some(target) = caller_target.as_deref_mut() {
878
+ write_claim_target_pane_nonce(target)?;
879
+ }
738
880
  let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
739
- let observed_endpoint = caller_target.and_then(|target| target.endpoint.as_deref());
881
+ let observed_endpoint = caller_target
882
+ .as_deref()
883
+ .and_then(|target| target.endpoint.as_deref());
740
884
  let convergence_candidate = observed_endpoint.or(current_endpoint.as_deref());
741
885
  let candidate_source = if observed_endpoint.is_some() {
742
886
  Some("observed_target_endpoint")
@@ -758,7 +902,7 @@ fn claim_lease_no_incident_with_target(
758
902
  pane_info,
759
903
  observed_endpoint.or(current_endpoint.as_deref()),
760
904
  scoped_team.or(team),
761
- caller_target,
905
+ caller_target.as_deref(),
762
906
  )
763
907
  });
764
908
  if converged || observation_refreshed {
@@ -924,23 +1068,33 @@ fn claim_lease_no_incident_with_target(
924
1068
  } else {
925
1069
  LeaseReason::VacantAcquired
926
1070
  };
927
- write_claim_target_pane_nonce(caller_target)?;
1071
+ if let Some(target) = caller_target.as_deref_mut() {
1072
+ write_claim_target_pane_nonce(target)?;
1073
+ }
928
1074
  let mut identity = leader_identity_context(workspace, Some(team_id.as_str()), Some(state))?;
929
- if let Some(uuid) = caller_target.and_then(|target| target.leader_session_uuid.as_ref()) {
1075
+ if let Some(uuid) = caller_target
1076
+ .as_deref()
1077
+ .and_then(|target| target.leader_session_uuid.as_ref()) {
930
1078
  identity.leader_session_uuid = uuid.clone();
931
1079
  }
932
1080
  let next_epoch = OwnerEpoch(pre_epoch.0.saturating_add(1));
933
- let provider = caller_target.map_or_else(|| prior_provider(state), |target| target.provider);
934
- let observed_endpoint = caller_target.and_then(|target| target.endpoint.clone());
1081
+ let provider = caller_target
1082
+ .as_deref()
1083
+ .map_or_else(|| prior_provider(state), |target| target.provider);
1084
+ let observed_endpoint = caller_target
1085
+ .as_deref()
1086
+ .and_then(|target| target.endpoint.clone());
935
1087
  let mut receiver = make_receiver(
936
1088
  provider,
937
1089
  &non_empty_caller_pane,
938
1090
  &identity.leader_session_uuid,
939
1091
  next_epoch,
940
1092
  Discovery::ClaimLeader,
941
- caller_target.and_then(|target| target.pane_info.clone()),
1093
+ caller_target
1094
+ .as_deref()
1095
+ .and_then(|target| target.pane_info.clone()),
942
1096
  );
943
- if let Some(target) = caller_target {
1097
+ if let Some(target) = caller_target.as_deref() {
944
1098
  receiver.scope_authority.clone_from(&target.scope_authority);
945
1099
  receiver
946
1100
  .authorized_team_workspace
@@ -1540,10 +1694,10 @@ fn workspace_claim_target_from_pane_info(
1540
1694
  claim_target_from_pane_info(target)
1541
1695
  }
1542
1696
 
1543
- fn write_claim_target_pane_nonce(target: Option<&LeaderClaimTarget>) -> Result<(), LeaderError> {
1544
- let Some(target) = target.filter(|target| target.scope_authority.is_some()) else {
1697
+ fn write_claim_target_pane_nonce(target: &mut LeaderClaimTarget) -> Result<(), LeaderError> {
1698
+ if target.scope_authority.is_none() {
1545
1699
  return Ok(());
1546
- };
1700
+ }
1547
1701
  let endpoint = target.endpoint.as_deref().ok_or_else(|| {
1548
1702
  LeaderError::Validation("explicit claim is missing its tmux endpoint".to_string())
1549
1703
  })?;
@@ -1557,13 +1711,30 @@ fn write_claim_target_pane_nonce(target: Option<&LeaderClaimTarget>) -> Result<(
1557
1711
  let nonce = target.binding_nonce.as_deref().ok_or_else(|| {
1558
1712
  LeaderError::Validation("explicit claim is missing its pane binding nonce".to_string())
1559
1713
  })?;
1560
- tmux_backend_for_endpoint(endpoint)
1561
- .set_pane_binding_nonce(pane, nonce)
1562
- .map_err(|error| {
1563
- LeaderError::Validation(format!(
1564
- "failed to bind the explicit leader pane instance: {error}"
1565
- ))
1714
+ let live_nonce = target
1715
+ .pane_info
1716
+ .as_ref()
1717
+ .and_then(|info| {
1718
+ info.leader_env
1719
+ .get(crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY)
1720
+ .map(String::as_str)
1566
1721
  })
1722
+ .filter(|nonce| !nonce.is_empty());
1723
+ // The pane option is the pane-instance identity shared by all authorized
1724
+ // teams. Rewriting an observed value would revoke another team's grant.
1725
+ let effective_nonce = if let Some(live_nonce) = live_nonce {
1726
+ live_nonce.to_string()
1727
+ } else {
1728
+ tmux_backend_for_endpoint(endpoint)
1729
+ .set_pane_binding_nonce_if_unset(pane, nonce)
1730
+ .map_err(|error| {
1731
+ LeaderError::Validation(format!(
1732
+ "failed to bind the explicit leader pane instance: {error}"
1733
+ ))
1734
+ })?
1735
+ };
1736
+ target.binding_nonce = Some(effective_nonce);
1737
+ Ok(())
1567
1738
  }
1568
1739
 
1569
1740
  fn canonical_workspace(workspace: &Path) -> PathBuf {
@@ -1591,6 +1762,18 @@ fn next_pane_binding_nonce(workspace: &Path, endpoint: &str, pane: &PaneId) -> S
1591
1762
  .collect()
1592
1763
  }
1593
1764
 
1765
+ fn pane_binding_nonce_for_claim(
1766
+ workspace: &Path,
1767
+ endpoint: &str,
1768
+ pane: &PaneId,
1769
+ live_nonce: Option<&str>,
1770
+ ) -> String {
1771
+ live_nonce
1772
+ .filter(|nonce| !nonce.is_empty())
1773
+ .map(str::to_string)
1774
+ .unwrap_or_else(|| next_pane_binding_nonce(workspace, endpoint, pane))
1775
+ }
1776
+
1594
1777
  fn target_leader_session_uuid(target: &PaneInfo) -> Option<crate::model::ids::LeaderSessionUuid> {
1595
1778
  target
1596
1779
  .leader_env
@@ -1799,6 +1982,72 @@ fn emit_lease_refusal(
1799
1982
  Ok(())
1800
1983
  }
1801
1984
 
1985
+ fn authorize_fresh_caller_receiver(
1986
+ workspace: &Path,
1987
+ receiver: &mut LeaderReceiver,
1988
+ pane: &NonEmptyPaneId,
1989
+ source: LeaseSource,
1990
+ require_current: bool,
1991
+ caller_target: Option<&PaneInfo>,
1992
+ nonce_writer: Option<&dyn Fn(&str, &PaneId, &str) -> Result<String, LeaderError>>,
1993
+ ) -> Result<(), LeaderError> {
1994
+ if !matches!(source, LeaseSource::QuickStart) || !require_current {
1995
+ return Ok(());
1996
+ }
1997
+ let Some(target) = caller_target else {
1998
+ return Ok(());
1999
+ };
2000
+ if target.pane_id.as_str() != pane.as_pane_id().as_str() || !target.active {
2001
+ return Err(LeaderError::Validation(
2002
+ "fresh caller pane observation does not match the bound pane".to_string(),
2003
+ ));
2004
+ }
2005
+ let Some(current_path) = target.current_path.as_deref() else {
2006
+ return Ok(());
2007
+ };
2008
+ if crate::messaging::leader_channel::path_is_in_workspace(current_path, workspace) {
2009
+ return Ok(());
2010
+ }
2011
+ let Some(endpoint) = receiver
2012
+ .tmux_socket
2013
+ .as_deref()
2014
+ .filter(|endpoint| !endpoint.is_empty())
2015
+ else {
2016
+ return Err(LeaderError::Validation(
2017
+ "fresh cross-workspace caller requires a tmux endpoint".to_string(),
2018
+ ));
2019
+ };
2020
+ if !Path::new(endpoint).is_absolute() {
2021
+ return Err(LeaderError::Validation(
2022
+ "fresh cross-workspace caller requires an absolute tmux endpoint".to_string(),
2023
+ ));
2024
+ }
2025
+ let pane_id = pane.as_pane_id();
2026
+ let live_nonce = if let Some(live_nonce) = target
2027
+ .leader_env
2028
+ .get(crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY)
2029
+ .filter(|nonce| !nonce.is_empty())
2030
+ .cloned()
2031
+ {
2032
+ live_nonce
2033
+ } else {
2034
+ let nonce = pane_binding_nonce_for_claim(workspace, endpoint, pane_id, None);
2035
+ if let Some(writer) = nonce_writer {
2036
+ writer(endpoint, pane_id, &nonce)?
2037
+ } else {
2038
+ tmux_backend_for_endpoint(endpoint)
2039
+ .set_pane_binding_nonce_if_unset(pane_id, &nonce)
2040
+ .map_err(|error| LeaderError::Tmux(error.to_string()))?
2041
+ }
2042
+ };
2043
+ receiver.scope_authority = Some(
2044
+ crate::messaging::leader_channel::FRESH_CALLER_SCOPE_AUTHORITY.to_string(),
2045
+ );
2046
+ receiver.authorized_team_workspace = Some(canonical_workspace(workspace));
2047
+ receiver.binding_nonce = Some(live_nonce);
2048
+ Ok(())
2049
+ }
2050
+
1802
2051
  fn make_receiver(
1803
2052
  provider: Provider,
1804
2053
  pane: &NonEmptyPaneId,
@@ -2166,6 +2415,24 @@ fn state_owner(state: &Value) -> Option<TeamOwner> {
2166
2415
  /// now persists ONLY the canonical root state; retaining the public
2167
2416
  /// name for 0.5.x call-site stability. The B0 legacy snapshot is
2168
2417
  /// diagnostic-only via `lifecycle::save_team_runtime_snapshot`.
2418
+ pub(crate) fn write_lease_dual_state_with_expected(
2419
+ workspace: &Path,
2420
+ state: &Value,
2421
+ expected_owner: &Value,
2422
+ expected_receiver: &Value,
2423
+ ) -> Result<(), LeaderError> {
2424
+ let team_key = canonical_owner_write_key(state);
2425
+ crate::state::repository::StateRepository::new(workspace).save(
2426
+ crate::state::repository::StateWriteIntent::ClaimLeaderFreshCaller {
2427
+ team_key: team_key.as_str(),
2428
+ expected_owner,
2429
+ expected_receiver,
2430
+ },
2431
+ state,
2432
+ )?;
2433
+ Ok(())
2434
+ }
2435
+
2169
2436
  pub fn write_lease_dual_state(workspace: &Path, state: &Value) -> Result<(), LeaderError> {
2170
2437
  // 0.5.42 S1b (s1b-writer-cluster-locate.md §4.4): terminal root
2171
2438
  // save routes through `StateRepository` with the `ClaimLeader`
@@ -2568,6 +2835,20 @@ mod tests {
2568
2835
  assert_ne!(first, second);
2569
2836
  }
2570
2837
 
2838
+ #[test]
2839
+ fn explicit_claim_generates_nonce_only_when_live_nonce_is_missing() {
2840
+ let workspace = Path::new("/tmp/workspace-a");
2841
+ let endpoint = "/tmp/team-agent-live.sock";
2842
+ let pane = PaneId::new("%7");
2843
+ let generated = pane_binding_nonce_for_claim(workspace, endpoint, &pane, None);
2844
+ assert_eq!(generated.len(), 32);
2845
+ assert!(generated.chars().all(|ch| ch.is_ascii_hexdigit()));
2846
+ assert_eq!(
2847
+ pane_binding_nonce_for_claim(workspace, endpoint, &pane, Some("live-nonce")),
2848
+ "live-nonce"
2849
+ );
2850
+ }
2851
+
2571
2852
  #[test]
2572
2853
  fn claim_authority_preserves_recorded_absolute_observation() {
2573
2854
  let workspace = Path::new("/tmp/workspace-a");