@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +19 -13
- package/crates/team-agent/src/cli/emit.rs +6 -3
- package/crates/team-agent/src/cli/mod.rs +151 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +28 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +3 -0
- package/crates/team-agent/src/cli/types.rs +6 -0
- package/crates/team-agent/src/compiler.rs +14 -1
- package/crates/team-agent/src/layout/worker_env.rs +366 -0
- package/crates/team-agent/src/leader/lease.rs +334 -53
- package/crates/team-agent/src/leader/registry.rs +177 -108
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +12 -1
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +2272 -128
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +16 -6
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +138 -13
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +5 -3
- package/crates/team-agent/src/lifecycle/restart/common.rs +3 -11
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +60 -0
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +31 -0
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +25 -23
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +110 -31
- package/crates/team-agent/src/messaging/delivery.rs +226 -37
- package/crates/team-agent/src/messaging/leader_channel.rs +12 -3
- package/crates/team-agent/src/messaging/tests/runtime.rs +234 -0
- package/crates/team-agent/src/model/spec.rs +81 -7
- package/crates/team-agent/src/provider/adapters/pi.rs +14 -0
- package/crates/team-agent/src/state/persist.rs +433 -4
- package/crates/team-agent/src/state/repository.rs +45 -2
- package/crates/team-agent/src/tmux_backend/tests.rs +414 -7
- package/crates/team-agent/src/tmux_backend.rs +274 -26
- package/crates/team-agent/src/transport/test_support.rs +18 -3
- package/crates/team-agent/src/transport.rs +99 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +3 -0
- package/skills/team-agent/command-coverage.json +21 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
570
|
+
binding_epoch,
|
|
452
571
|
Discovery::EnvPane,
|
|
453
572
|
None,
|
|
454
573
|
);
|
|
455
|
-
|
|
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
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
LeaderIncident::LeaderAttached,
|
|
593
|
+
state,
|
|
594
|
+
expected_owner,
|
|
595
|
+
expected_receiver,
|
|
481
596
|
)?;
|
|
482
|
-
|
|
597
|
+
} else {
|
|
598
|
+
write_lease_dual_state(workspace, state)?;
|
|
483
599
|
}
|
|
484
|
-
|
|
485
|
-
|
|
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":
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
934
|
-
|
|
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
|
|
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:
|
|
1544
|
-
|
|
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
|
-
|
|
1561
|
-
.
|
|
1562
|
-
.
|
|
1563
|
-
|
|
1564
|
-
|
|
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");
|