@team-agent/installer 0.5.51 → 0.5.53
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 +18 -2
- package/crates/team-agent/src/cli/emit.rs +17 -0
- package/crates/team-agent/src/cli/mod.rs +155 -4
- package/crates/team-agent/src/cli/named_address.rs +35 -67
- package/crates/team-agent/src/cli/spec.rs +3 -0
- package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +11 -0
- package/crates/team-agent/src/diagnose/orphans.rs +24 -3
- package/crates/team-agent/src/kill_audit.rs +67 -0
- package/crates/team-agent/src/leader/lease.rs +526 -73
- package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
- package/crates/team-agent/src/leader/rediscover.rs +6 -0
- package/crates/team-agent/src/leader/start.rs +144 -23
- package/crates/team-agent/src/leader/tests/idle.rs +3 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +12 -1
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -8
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch.rs +14 -2
- package/crates/team-agent/src/lifecycle/restart/agent.rs +20 -4
- package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +113 -4
- package/crates/team-agent/src/lifecycle/restart/remove.rs +73 -10
- package/crates/team-agent/src/lifecycle/restart.rs +25 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +441 -12
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +20 -2
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
- package/crates/team-agent/src/lifecycle/types.rs +17 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
- package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
- package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
- package/crates/team-agent/src/mcp_server/tests.rs +11 -4
- package/crates/team-agent/src/mcp_server/tools.rs +18 -4
- package/crates/team-agent/src/mcp_server/types.rs +3 -0
- package/crates/team-agent/src/mcp_server/wire.rs +30 -7
- package/crates/team-agent/src/messaging/delivery.rs +126 -147
- package/crates/team-agent/src/messaging/leader_channel.rs +198 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
- package/crates/team-agent/src/messaging/mod.rs +5 -0
- package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
- package/crates/team-agent/src/provider/adapter.rs +54 -13
- package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
- package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
- package/crates/team-agent/src/provider/session/capture.rs +321 -52
- package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
- package/crates/team-agent/src/provider/session/mod.rs +6 -0
- package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
- package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
- package/crates/team-agent/src/provider/session_scan/common.rs +39 -6
- package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
- package/crates/team-agent/src/provider/session_scan.rs +3 -3
- package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
- package/crates/team-agent/src/provider/tests.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +282 -20
- package/crates/team-agent/src/state/projection.rs +245 -30
- package/crates/team-agent/src/state/repository/intent.rs +13 -0
- package/crates/team-agent/src/state/repository.rs +36 -12
- package/crates/team-agent/src/state/selector.rs +9 -18
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
- package/crates/team-agent/src/tmux_backend.rs +29 -2
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +8 -3
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
use std::collections::BTreeSet;
|
|
5
5
|
use std::path::{Path, PathBuf};
|
|
6
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
7
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
6
8
|
|
|
7
9
|
use serde_json::{json, Value};
|
|
10
|
+
use sha2::{Digest, Sha256};
|
|
8
11
|
|
|
9
12
|
use crate::message_store::MessageStore;
|
|
10
13
|
use crate::model::enums::PaneLiveness;
|
|
@@ -357,6 +360,29 @@ fn push_state_tmux_endpoints(state: &Value, out: &mut BTreeSet<String>) {
|
|
|
357
360
|
}
|
|
358
361
|
}
|
|
359
362
|
|
|
363
|
+
fn state_records_pane_endpoint(state: &Value, pane_id: &str, endpoint: &str) -> bool {
|
|
364
|
+
let record_matches = |record: &Value| {
|
|
365
|
+
record.get("pane_id").and_then(Value::as_str) == Some(pane_id)
|
|
366
|
+
&& record
|
|
367
|
+
.get("tmux_socket")
|
|
368
|
+
.and_then(Value::as_str)
|
|
369
|
+
.is_some_and(|recorded| tmux_endpoints_match(recorded, endpoint))
|
|
370
|
+
};
|
|
371
|
+
["team_owner", "leader_receiver"]
|
|
372
|
+
.iter()
|
|
373
|
+
.any(|key| state.get(*key).is_some_and(&record_matches))
|
|
374
|
+
|| state
|
|
375
|
+
.get("teams")
|
|
376
|
+
.and_then(Value::as_object)
|
|
377
|
+
.is_some_and(|teams| {
|
|
378
|
+
teams.values().any(|team| {
|
|
379
|
+
["team_owner", "leader_receiver"]
|
|
380
|
+
.iter()
|
|
381
|
+
.any(|key| team.get(*key).is_some_and(&record_matches))
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
360
386
|
fn requeue_exhausted_watchers_after_attach(
|
|
361
387
|
workspace: &Path,
|
|
362
388
|
state: &Value,
|
|
@@ -489,41 +515,50 @@ pub fn claim_leader(
|
|
|
489
515
|
.unwrap_or_default();
|
|
490
516
|
let raw_state = crate::state::persist::load_runtime_state_without_migrations(workspace)?;
|
|
491
517
|
let event_log = crate::event_log::EventLog::new(workspace);
|
|
518
|
+
let active_team = crate::messaging::leader_receiver::active_team_key(workspace, &raw_state);
|
|
519
|
+
let requested_team = team
|
|
520
|
+
.filter(|team| !team.is_empty())
|
|
521
|
+
.or_else(|| {
|
|
522
|
+
raw_state
|
|
523
|
+
.get("active_team_key")
|
|
524
|
+
.and_then(Value::as_str)
|
|
525
|
+
.filter(|team| !team.is_empty())
|
|
526
|
+
})
|
|
527
|
+
.unwrap_or(active_team.as_str());
|
|
528
|
+
let team_id = TeamKey::new(requested_team.to_string());
|
|
492
529
|
let targets = claim_leader_targets(workspace, &raw_state);
|
|
493
|
-
let caller_candidate =
|
|
494
|
-
.
|
|
495
|
-
.filter(|target| target.info.pane_id.as_str() == caller)
|
|
496
|
-
.min_by_key(|target| target.source.priority());
|
|
530
|
+
let caller_candidate =
|
|
531
|
+
select_claim_authority(workspace, &raw_state, team_id.as_str(), &targets, &caller);
|
|
497
532
|
let caller_pane_info = caller_candidate.map(|target| &target.info);
|
|
498
|
-
let
|
|
499
|
-
|
|
500
|
-
|
|
533
|
+
let observed_endpoint = caller_candidate.and_then(|candidate| {
|
|
534
|
+
authoritative_observed_endpoint(workspace, &raw_state, &targets, candidate)
|
|
535
|
+
});
|
|
536
|
+
let mut caller_target = caller_candidate.and_then(|target| {
|
|
537
|
+
claim_target_from_pane_info(&target.info).map(|mut claim_target| {
|
|
538
|
+
claim_target.endpoint.clone_from(&observed_endpoint);
|
|
501
539
|
claim_target
|
|
502
540
|
})
|
|
503
541
|
});
|
|
504
|
-
let
|
|
505
|
-
.
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
.map(str::to_string)
|
|
525
|
-
.unwrap_or_else(|| active_team.clone()),
|
|
526
|
-
);
|
|
542
|
+
if let (Some(candidate), Some(target)) = (caller_candidate, caller_target.as_mut()) {
|
|
543
|
+
if candidate.info.current_path.as_deref().is_some_and(|path| {
|
|
544
|
+
!crate::messaging::leader_channel::path_is_in_workspace(path, workspace)
|
|
545
|
+
}) {
|
|
546
|
+
let endpoint = target.endpoint.as_deref().ok_or_else(|| {
|
|
547
|
+
LeaderError::Validation(
|
|
548
|
+
"explicit cross-workspace claim requires a canonical tmux endpoint".to_string(),
|
|
549
|
+
)
|
|
550
|
+
})?;
|
|
551
|
+
if !Path::new(endpoint).is_absolute() {
|
|
552
|
+
return Err(LeaderError::Validation(
|
|
553
|
+
"explicit cross-workspace claim requires an absolute tmux endpoint".to_string(),
|
|
554
|
+
));
|
|
555
|
+
}
|
|
556
|
+
let nonce = next_pane_binding_nonce(workspace, endpoint, &candidate.info.pane_id);
|
|
557
|
+
target.scope_authority = Some("explicit_claim".to_string());
|
|
558
|
+
target.authorized_team_workspace = Some(canonical_workspace(workspace));
|
|
559
|
+
target.binding_nonce = Some(nonce);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
527
562
|
let scoped_team = if team_id.as_str() == active_team
|
|
528
563
|
|| raw_state
|
|
529
564
|
.get("teams")
|
|
@@ -695,6 +730,7 @@ fn claim_lease_no_incident_with_target(
|
|
|
695
730
|
let non_empty_caller_pane = NonEmptyPaneId::try_from_pane(caller_pane)?;
|
|
696
731
|
let bound_endpoint_matches_caller = bound_endpoint_matches_current_process(state);
|
|
697
732
|
if bound_pane_id.as_deref() == Some(caller_pane.as_str()) && bound_endpoint_matches_caller {
|
|
733
|
+
write_claim_target_pane_nonce(caller_target)?;
|
|
698
734
|
let current_endpoint = crate::tmux_backend::socket_name_from_tmux_env();
|
|
699
735
|
let observed_endpoint = caller_target.and_then(|target| target.endpoint.as_deref());
|
|
700
736
|
let convergence_candidate = observed_endpoint.or(current_endpoint.as_deref());
|
|
@@ -712,25 +748,47 @@ fn claim_lease_no_incident_with_target(
|
|
|
712
748
|
candidate_source,
|
|
713
749
|
pre_epoch,
|
|
714
750
|
);
|
|
715
|
-
|
|
751
|
+
let observation_refreshed = caller_pane_info.is_some_and(|pane_info| {
|
|
752
|
+
refresh_already_bound_receiver_observation(
|
|
753
|
+
state,
|
|
754
|
+
pane_info,
|
|
755
|
+
observed_endpoint.or(current_endpoint.as_deref()),
|
|
756
|
+
scoped_team.or(team),
|
|
757
|
+
caller_target,
|
|
758
|
+
)
|
|
759
|
+
});
|
|
760
|
+
if converged || observation_refreshed {
|
|
716
761
|
write_claim_state(workspace, state, scoped_team, team)?;
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
{
|
|
729
|
-
|
|
762
|
+
if observation_refreshed {
|
|
763
|
+
event_log.write(
|
|
764
|
+
"leader_receiver.observation_refreshed",
|
|
765
|
+
json!({
|
|
766
|
+
"pane_id": caller_pane.as_str(),
|
|
767
|
+
"owner_epoch": pre_epoch.0,
|
|
768
|
+
"owner_changed": false,
|
|
769
|
+
"source": "claim_leader_already_bound",
|
|
770
|
+
}),
|
|
771
|
+
)?;
|
|
772
|
+
}
|
|
773
|
+
if converged {
|
|
774
|
+
topology_convergence = verify_persisted_topology_convergence(
|
|
775
|
+
workspace,
|
|
776
|
+
team_id.as_str(),
|
|
730
777
|
topology_convergence,
|
|
731
778
|
pre_epoch,
|
|
732
|
-
|
|
733
|
-
|
|
779
|
+
)?;
|
|
780
|
+
if topology_convergence
|
|
781
|
+
.as_ref()
|
|
782
|
+
.and_then(|metadata| metadata.get("status"))
|
|
783
|
+
.and_then(Value::as_str)
|
|
784
|
+
!= Some("converged")
|
|
785
|
+
{
|
|
786
|
+
return Ok(convergence_persistence_refusal(
|
|
787
|
+
topology_convergence,
|
|
788
|
+
pre_epoch,
|
|
789
|
+
Some(caller_pane.clone()),
|
|
790
|
+
));
|
|
791
|
+
}
|
|
734
792
|
}
|
|
735
793
|
}
|
|
736
794
|
return Ok(LeaseResult {
|
|
@@ -862,6 +920,7 @@ fn claim_lease_no_incident_with_target(
|
|
|
862
920
|
} else {
|
|
863
921
|
LeaseReason::VacantAcquired
|
|
864
922
|
};
|
|
923
|
+
write_claim_target_pane_nonce(caller_target)?;
|
|
865
924
|
let mut identity = leader_identity_context(workspace, Some(team_id.as_str()), Some(state))?;
|
|
866
925
|
if let Some(uuid) = caller_target.and_then(|target| target.leader_session_uuid.as_ref()) {
|
|
867
926
|
identity.leader_session_uuid = uuid.clone();
|
|
@@ -877,6 +936,13 @@ fn claim_lease_no_incident_with_target(
|
|
|
877
936
|
Discovery::ClaimLeader,
|
|
878
937
|
caller_target.and_then(|target| target.pane_info.clone()),
|
|
879
938
|
);
|
|
939
|
+
if let Some(target) = caller_target {
|
|
940
|
+
receiver.scope_authority.clone_from(&target.scope_authority);
|
|
941
|
+
receiver
|
|
942
|
+
.authorized_team_workspace
|
|
943
|
+
.clone_from(&target.authorized_team_workspace);
|
|
944
|
+
receiver.binding_nonce.clone_from(&target.binding_nonce);
|
|
945
|
+
}
|
|
880
946
|
if let Some(endpoint) = observed_endpoint.as_ref() {
|
|
881
947
|
receiver.tmux_socket = Some(endpoint.clone());
|
|
882
948
|
}
|
|
@@ -1002,6 +1068,8 @@ fn apply_endpoint_convergence(
|
|
|
1002
1068
|
"new_tmux_endpoint": candidate_endpoint,
|
|
1003
1069
|
"candidate_source": candidate_source,
|
|
1004
1070
|
"owner_epoch": owner_epoch.0,
|
|
1071
|
+
"persisted": false,
|
|
1072
|
+
"checked_paths": convergence_checked_paths(team_id.as_str()),
|
|
1005
1073
|
})),
|
|
1006
1074
|
false,
|
|
1007
1075
|
),
|
|
@@ -1017,6 +1085,8 @@ fn apply_endpoint_convergence(
|
|
|
1017
1085
|
"reason": reason,
|
|
1018
1086
|
"candidate_source": candidate_source,
|
|
1019
1087
|
"owner_epoch": owner_epoch.0,
|
|
1088
|
+
"persisted": false,
|
|
1089
|
+
"checked_paths": convergence_checked_paths(team_id.as_str()),
|
|
1020
1090
|
"action": format!(
|
|
1021
1091
|
"old tmux endpoint {old_endpoint} still has this team's session or pane tuple; run team-agent diagnose --json before retrying restart"
|
|
1022
1092
|
),
|
|
@@ -1292,32 +1362,89 @@ struct LeaderClaimTarget {
|
|
|
1292
1362
|
team_id: Option<String>,
|
|
1293
1363
|
pane_info: Option<PaneInfo>,
|
|
1294
1364
|
endpoint: Option<String>,
|
|
1365
|
+
scope_authority: Option<String>,
|
|
1366
|
+
authorized_team_workspace: Option<PathBuf>,
|
|
1367
|
+
binding_nonce: Option<String>,
|
|
1295
1368
|
}
|
|
1296
1369
|
|
|
1297
1370
|
#[derive(Clone)]
|
|
1298
1371
|
struct ClaimLeaderTargetCandidate {
|
|
1299
1372
|
info: PaneInfo,
|
|
1300
1373
|
endpoint: Option<String>,
|
|
1301
|
-
source: ClaimLeaderTargetSource,
|
|
1302
1374
|
}
|
|
1303
1375
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1376
|
+
fn select_claim_authority<'a>(
|
|
1377
|
+
workspace: &Path,
|
|
1378
|
+
state: &Value,
|
|
1379
|
+
team_id: &str,
|
|
1380
|
+
targets: &'a [ClaimLeaderTargetCandidate],
|
|
1381
|
+
caller: &str,
|
|
1382
|
+
) -> Option<&'a ClaimLeaderTargetCandidate> {
|
|
1383
|
+
targets
|
|
1384
|
+
.iter()
|
|
1385
|
+
.filter(|target| {
|
|
1386
|
+
target.info.pane_id.as_str() == caller
|
|
1387
|
+
&& target.info.active
|
|
1388
|
+
&& super::attribute_pane_provider(&target.info).is_some()
|
|
1389
|
+
})
|
|
1390
|
+
.min_by_key(|target| {
|
|
1391
|
+
let recorded = target
|
|
1392
|
+
.endpoint
|
|
1393
|
+
.as_deref()
|
|
1394
|
+
.is_some_and(|endpoint| state_records_pane_endpoint(state, caller, endpoint));
|
|
1395
|
+
let in_workspace = target.info.current_path.as_deref().is_some_and(|path| {
|
|
1396
|
+
crate::messaging::leader_channel::path_is_in_workspace(path, workspace)
|
|
1397
|
+
});
|
|
1398
|
+
let team_affinity = match target
|
|
1399
|
+
.info
|
|
1400
|
+
.leader_env
|
|
1401
|
+
.get("TEAM_AGENT_TEAM_ID")
|
|
1402
|
+
.filter(|value| !value.is_empty())
|
|
1403
|
+
{
|
|
1404
|
+
Some(observed) if observed == team_id => 0,
|
|
1405
|
+
None => 1,
|
|
1406
|
+
Some(_) => 2,
|
|
1407
|
+
};
|
|
1408
|
+
(
|
|
1409
|
+
!recorded,
|
|
1410
|
+
!in_workspace,
|
|
1411
|
+
team_affinity,
|
|
1412
|
+
target.info.pane_pid.unwrap_or(u32::MAX),
|
|
1413
|
+
target.info.session.as_str().to_string(),
|
|
1414
|
+
target.info.window_index.unwrap_or(u32::MAX),
|
|
1415
|
+
target.info.pane_index.unwrap_or(u32::MAX),
|
|
1416
|
+
target.info.tty.clone().unwrap_or_default(),
|
|
1417
|
+
)
|
|
1418
|
+
})
|
|
1310
1419
|
}
|
|
1311
1420
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1421
|
+
fn authoritative_observed_endpoint(
|
|
1422
|
+
workspace: &Path,
|
|
1423
|
+
state: &Value,
|
|
1424
|
+
targets: &[ClaimLeaderTargetCandidate],
|
|
1425
|
+
authority: &ClaimLeaderTargetCandidate,
|
|
1426
|
+
) -> Option<String> {
|
|
1427
|
+
let recorded = state_recorded_tmux_endpoints(state);
|
|
1428
|
+
let workspace_name = crate::tmux_backend::socket_name_for_workspace(workspace);
|
|
1429
|
+
let workspace_path = crate::tmux_backend::socket_path_for_workspace(workspace);
|
|
1430
|
+
targets
|
|
1431
|
+
.iter()
|
|
1432
|
+
.filter(|candidate| candidate.info == authority.info)
|
|
1433
|
+
.filter_map(|candidate| candidate.endpoint.as_ref())
|
|
1434
|
+
.min_by_key(|endpoint| {
|
|
1435
|
+
let canonical_workspace_endpoint = endpoint.as_str() == workspace_name
|
|
1436
|
+
|| workspace_path
|
|
1437
|
+
.as_ref()
|
|
1438
|
+
.is_some_and(|path| path.to_string_lossy().as_ref() == endpoint.as_str());
|
|
1439
|
+
(
|
|
1440
|
+
!state_records_pane_endpoint(state, authority.info.pane_id.as_str(), endpoint),
|
|
1441
|
+
!recorded.contains(endpoint.as_str()),
|
|
1442
|
+
!canonical_workspace_endpoint,
|
|
1443
|
+
!Path::new(endpoint.as_str()).is_absolute(),
|
|
1444
|
+
endpoint.as_str(),
|
|
1445
|
+
)
|
|
1446
|
+
})
|
|
1447
|
+
.cloned()
|
|
1321
1448
|
}
|
|
1322
1449
|
|
|
1323
1450
|
fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTargetCandidate> {
|
|
@@ -1333,7 +1460,6 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
|
|
|
1333
1460
|
.map(|info| ClaimLeaderTargetCandidate {
|
|
1334
1461
|
info,
|
|
1335
1462
|
endpoint: resolved_endpoint.clone(),
|
|
1336
|
-
source: ClaimLeaderTargetSource::CurrentTmux,
|
|
1337
1463
|
}),
|
|
1338
1464
|
);
|
|
1339
1465
|
}
|
|
@@ -1348,7 +1474,6 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
|
|
|
1348
1474
|
.map(|info| ClaimLeaderTargetCandidate {
|
|
1349
1475
|
info,
|
|
1350
1476
|
endpoint: resolved_endpoint.clone(),
|
|
1351
|
-
source: ClaimLeaderTargetSource::StateRecorded,
|
|
1352
1477
|
}),
|
|
1353
1478
|
);
|
|
1354
1479
|
}
|
|
@@ -1362,7 +1487,6 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
|
|
|
1362
1487
|
.map(|info| ClaimLeaderTargetCandidate {
|
|
1363
1488
|
info,
|
|
1364
1489
|
endpoint: workspace_endpoint.clone(),
|
|
1365
|
-
source: ClaimLeaderTargetSource::Workspace,
|
|
1366
1490
|
}),
|
|
1367
1491
|
);
|
|
1368
1492
|
let default_backend = crate::transport_factory::tmux_default_transport();
|
|
@@ -1375,21 +1499,16 @@ fn claim_leader_targets(workspace: &Path, state: &Value) -> Vec<ClaimLeaderTarge
|
|
|
1375
1499
|
.map(|info| ClaimLeaderTargetCandidate {
|
|
1376
1500
|
info,
|
|
1377
1501
|
endpoint: default_endpoint.clone(),
|
|
1378
|
-
source: ClaimLeaderTargetSource::Default,
|
|
1379
1502
|
}),
|
|
1380
1503
|
);
|
|
1381
1504
|
targets
|
|
1382
1505
|
}
|
|
1383
1506
|
|
|
1384
|
-
fn claim_target_from_pane_info(
|
|
1507
|
+
fn claim_target_from_pane_info(target: &PaneInfo) -> Option<LeaderClaimTarget> {
|
|
1385
1508
|
if !target.active {
|
|
1386
1509
|
return None;
|
|
1387
1510
|
}
|
|
1388
1511
|
let provider = super::attribute_pane_provider(target)?;
|
|
1389
|
-
let current_path = target.current_path.as_deref()?;
|
|
1390
|
-
if !crate::state::owner_gate::workspace_paths_match(current_path, workspace) {
|
|
1391
|
-
return None;
|
|
1392
|
-
}
|
|
1393
1512
|
Some(LeaderClaimTarget {
|
|
1394
1513
|
provider,
|
|
1395
1514
|
leader_session_uuid: target_leader_session_uuid(target),
|
|
@@ -1400,9 +1519,74 @@ fn claim_target_from_pane_info(workspace: &Path, target: &PaneInfo) -> Option<Le
|
|
|
1400
1519
|
.cloned(),
|
|
1401
1520
|
pane_info: Some(target.clone()),
|
|
1402
1521
|
endpoint: None,
|
|
1522
|
+
scope_authority: None,
|
|
1523
|
+
authorized_team_workspace: None,
|
|
1524
|
+
binding_nonce: None,
|
|
1403
1525
|
})
|
|
1404
1526
|
}
|
|
1405
1527
|
|
|
1528
|
+
fn workspace_claim_target_from_pane_info(
|
|
1529
|
+
workspace: &Path,
|
|
1530
|
+
target: &PaneInfo,
|
|
1531
|
+
) -> Option<LeaderClaimTarget> {
|
|
1532
|
+
let current_path = target.current_path.as_deref()?;
|
|
1533
|
+
if !crate::state::owner_gate::workspace_paths_match(current_path, workspace) {
|
|
1534
|
+
return None;
|
|
1535
|
+
}
|
|
1536
|
+
claim_target_from_pane_info(target)
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
fn write_claim_target_pane_nonce(target: Option<&LeaderClaimTarget>) -> Result<(), LeaderError> {
|
|
1540
|
+
let Some(target) = target.filter(|target| target.scope_authority.is_some()) else {
|
|
1541
|
+
return Ok(());
|
|
1542
|
+
};
|
|
1543
|
+
let endpoint = target.endpoint.as_deref().ok_or_else(|| {
|
|
1544
|
+
LeaderError::Validation("explicit claim is missing its tmux endpoint".to_string())
|
|
1545
|
+
})?;
|
|
1546
|
+
let pane = target
|
|
1547
|
+
.pane_info
|
|
1548
|
+
.as_ref()
|
|
1549
|
+
.map(|info| &info.pane_id)
|
|
1550
|
+
.ok_or_else(|| {
|
|
1551
|
+
LeaderError::Validation("explicit claim is missing its pane observation".to_string())
|
|
1552
|
+
})?;
|
|
1553
|
+
let nonce = target.binding_nonce.as_deref().ok_or_else(|| {
|
|
1554
|
+
LeaderError::Validation("explicit claim is missing its pane binding nonce".to_string())
|
|
1555
|
+
})?;
|
|
1556
|
+
tmux_backend_for_endpoint(endpoint)
|
|
1557
|
+
.set_pane_binding_nonce(pane, nonce)
|
|
1558
|
+
.map_err(|error| {
|
|
1559
|
+
LeaderError::Validation(format!(
|
|
1560
|
+
"failed to bind the explicit leader pane instance: {error}"
|
|
1561
|
+
))
|
|
1562
|
+
})
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
fn canonical_workspace(workspace: &Path) -> PathBuf {
|
|
1566
|
+
workspace
|
|
1567
|
+
.canonicalize()
|
|
1568
|
+
.unwrap_or_else(|_| workspace.to_path_buf())
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
fn next_pane_binding_nonce(workspace: &Path, endpoint: &str, pane: &PaneId) -> String {
|
|
1572
|
+
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
1573
|
+
let nanos = SystemTime::now()
|
|
1574
|
+
.duration_since(UNIX_EPOCH)
|
|
1575
|
+
.map_or(0, |duration| duration.as_nanos());
|
|
1576
|
+
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
1577
|
+
let material = format!(
|
|
1578
|
+
"{}\0{endpoint}\0{}\0{}\0{nanos}\0{counter}",
|
|
1579
|
+
canonical_workspace(workspace).display(),
|
|
1580
|
+
pane.as_str(),
|
|
1581
|
+
std::process::id(),
|
|
1582
|
+
);
|
|
1583
|
+
let digest = Sha256::digest(material.as_bytes());
|
|
1584
|
+
digest[..16]
|
|
1585
|
+
.iter()
|
|
1586
|
+
.map(|byte| format!("{byte:02x}"))
|
|
1587
|
+
.collect()
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1406
1590
|
fn target_leader_session_uuid(target: &PaneInfo) -> Option<crate::model::ids::LeaderSessionUuid> {
|
|
1407
1591
|
target
|
|
1408
1592
|
.leader_env
|
|
@@ -1426,7 +1610,7 @@ fn validate_attach_target(
|
|
|
1426
1610
|
// a real Codex/Claude/Copilot binary. This is a targeted escape
|
|
1427
1611
|
// hatch — `Provider::Fake` is not selectable from the user-facing
|
|
1428
1612
|
// provider list, only wired in test/fixture flows.
|
|
1429
|
-
let claim_target = match
|
|
1613
|
+
let claim_target = match workspace_claim_target_from_pane_info(workspace, target) {
|
|
1430
1614
|
Some(target) => Some(target),
|
|
1431
1615
|
None if matches!(requested_provider, Provider::Fake) => None,
|
|
1432
1616
|
None => return Err("leader_pane_validation_failed"),
|
|
@@ -1532,7 +1716,7 @@ impl TargetScanLiveness {
|
|
|
1532
1716
|
let live_panes = targets
|
|
1533
1717
|
.iter()
|
|
1534
1718
|
.filter_map(|target| {
|
|
1535
|
-
let claim_target =
|
|
1719
|
+
let claim_target = workspace_claim_target_from_pane_info(workspace, target)?;
|
|
1536
1720
|
if let Some(owner_uuid) = owner_uuid.as_deref() {
|
|
1537
1721
|
let target_uuid = claim_target.leader_session_uuid.as_ref()?.as_str();
|
|
1538
1722
|
if target_uuid != owner_uuid {
|
|
@@ -1635,6 +1819,9 @@ fn make_receiver(
|
|
|
1635
1819
|
pane_tty: target.as_ref().and_then(|t| t.tty.clone()),
|
|
1636
1820
|
pane_current_command: target.as_ref().and_then(|t| t.current_command.clone()),
|
|
1637
1821
|
tmux_socket: crate::tmux_backend::socket_name_from_tmux_env(),
|
|
1822
|
+
scope_authority: None,
|
|
1823
|
+
authorized_team_workspace: None,
|
|
1824
|
+
binding_nonce: None,
|
|
1638
1825
|
fingerprint: target.as_ref().map(receiver_fingerprint),
|
|
1639
1826
|
leader_session_uuid: Some(uuid.clone()),
|
|
1640
1827
|
owner_epoch: Some(epoch),
|
|
@@ -1659,6 +1846,132 @@ fn receiver_fingerprint(target: &PaneInfo) -> String {
|
|
|
1659
1846
|
)
|
|
1660
1847
|
}
|
|
1661
1848
|
|
|
1849
|
+
fn refresh_already_bound_receiver_observation(
|
|
1850
|
+
state: &mut Value,
|
|
1851
|
+
observed: &PaneInfo,
|
|
1852
|
+
observed_endpoint: Option<&str>,
|
|
1853
|
+
team_key: Option<&str>,
|
|
1854
|
+
claim_target: Option<&LeaderClaimTarget>,
|
|
1855
|
+
) -> bool {
|
|
1856
|
+
let attached_at = now_ts();
|
|
1857
|
+
let mut refreshed = state
|
|
1858
|
+
.get_mut("leader_receiver")
|
|
1859
|
+
.and_then(Value::as_object_mut)
|
|
1860
|
+
.is_some_and(|receiver| {
|
|
1861
|
+
refresh_receiver_observation(
|
|
1862
|
+
receiver,
|
|
1863
|
+
observed,
|
|
1864
|
+
observed_endpoint,
|
|
1865
|
+
attached_at.as_str(),
|
|
1866
|
+
claim_target,
|
|
1867
|
+
)
|
|
1868
|
+
});
|
|
1869
|
+
if let Some(team_key) = team_key {
|
|
1870
|
+
refreshed |= state
|
|
1871
|
+
.get_mut("teams")
|
|
1872
|
+
.and_then(|teams| teams.get_mut(team_key))
|
|
1873
|
+
.and_then(|entry| entry.get_mut("leader_receiver"))
|
|
1874
|
+
.and_then(Value::as_object_mut)
|
|
1875
|
+
.is_some_and(|receiver| {
|
|
1876
|
+
refresh_receiver_observation(
|
|
1877
|
+
receiver,
|
|
1878
|
+
observed,
|
|
1879
|
+
observed_endpoint,
|
|
1880
|
+
attached_at.as_str(),
|
|
1881
|
+
claim_target,
|
|
1882
|
+
)
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
refreshed
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
fn refresh_receiver_observation(
|
|
1889
|
+
receiver: &mut serde_json::Map<String, Value>,
|
|
1890
|
+
observed: &PaneInfo,
|
|
1891
|
+
observed_endpoint: Option<&str>,
|
|
1892
|
+
attached_at: &str,
|
|
1893
|
+
claim_target: Option<&LeaderClaimTarget>,
|
|
1894
|
+
) -> bool {
|
|
1895
|
+
let before = Value::Object(receiver.clone());
|
|
1896
|
+
receiver.insert(
|
|
1897
|
+
"session_name".to_string(),
|
|
1898
|
+
Value::String(observed.session.as_str().to_string()),
|
|
1899
|
+
);
|
|
1900
|
+
receiver.insert(
|
|
1901
|
+
"window_index".to_string(),
|
|
1902
|
+
observed
|
|
1903
|
+
.window_index
|
|
1904
|
+
.map(|value| Value::String(value.to_string()))
|
|
1905
|
+
.unwrap_or(Value::Null),
|
|
1906
|
+
);
|
|
1907
|
+
receiver.insert(
|
|
1908
|
+
"window_name".to_string(),
|
|
1909
|
+
observed
|
|
1910
|
+
.window_name
|
|
1911
|
+
.as_ref()
|
|
1912
|
+
.map(|value| Value::String(value.as_str().to_string()))
|
|
1913
|
+
.unwrap_or(Value::Null),
|
|
1914
|
+
);
|
|
1915
|
+
receiver.insert(
|
|
1916
|
+
"pane_index".to_string(),
|
|
1917
|
+
observed
|
|
1918
|
+
.pane_index
|
|
1919
|
+
.map(|value| Value::String(value.to_string()))
|
|
1920
|
+
.unwrap_or(Value::Null),
|
|
1921
|
+
);
|
|
1922
|
+
receiver.insert(
|
|
1923
|
+
"pane_tty".to_string(),
|
|
1924
|
+
observed
|
|
1925
|
+
.tty
|
|
1926
|
+
.as_ref()
|
|
1927
|
+
.map(|value| Value::String(value.clone()))
|
|
1928
|
+
.unwrap_or(Value::Null),
|
|
1929
|
+
);
|
|
1930
|
+
receiver.insert(
|
|
1931
|
+
"pane_current_command".to_string(),
|
|
1932
|
+
observed
|
|
1933
|
+
.current_command
|
|
1934
|
+
.as_ref()
|
|
1935
|
+
.map(|value| Value::String(value.clone()))
|
|
1936
|
+
.unwrap_or(Value::Null),
|
|
1937
|
+
);
|
|
1938
|
+
receiver.insert(
|
|
1939
|
+
"fingerprint".to_string(),
|
|
1940
|
+
Value::String(receiver_fingerprint(observed)),
|
|
1941
|
+
);
|
|
1942
|
+
if let Some(endpoint) = observed_endpoint.filter(|value| !value.is_empty()) {
|
|
1943
|
+
receiver.insert(
|
|
1944
|
+
"tmux_socket".to_string(),
|
|
1945
|
+
Value::String(endpoint.to_string()),
|
|
1946
|
+
);
|
|
1947
|
+
}
|
|
1948
|
+
if let Some(target) = claim_target.filter(|target| target.scope_authority.is_some()) {
|
|
1949
|
+
receiver.insert(
|
|
1950
|
+
"scope_authority".to_string(),
|
|
1951
|
+
Value::String(target.scope_authority.clone().unwrap_or_default()),
|
|
1952
|
+
);
|
|
1953
|
+
receiver.insert(
|
|
1954
|
+
"authorized_team_workspace".to_string(),
|
|
1955
|
+
Value::String(
|
|
1956
|
+
target
|
|
1957
|
+
.authorized_team_workspace
|
|
1958
|
+
.as_deref()
|
|
1959
|
+
.map(|path| path.to_string_lossy().into_owned())
|
|
1960
|
+
.unwrap_or_default(),
|
|
1961
|
+
),
|
|
1962
|
+
);
|
|
1963
|
+
receiver.insert(
|
|
1964
|
+
"binding_nonce".to_string(),
|
|
1965
|
+
Value::String(target.binding_nonce.clone().unwrap_or_default()),
|
|
1966
|
+
);
|
|
1967
|
+
}
|
|
1968
|
+
receiver.insert(
|
|
1969
|
+
"attached_at".to_string(),
|
|
1970
|
+
Value::String(attached_at.to_string()),
|
|
1971
|
+
);
|
|
1972
|
+
Value::Object(receiver.clone()) != before
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1662
1975
|
fn make_owner(
|
|
1663
1976
|
provider: Provider,
|
|
1664
1977
|
pane: &NonEmptyPaneId,
|
|
@@ -2124,6 +2437,22 @@ fn readable_team_snapshot_path(w: &BP, n: &str) -> BF /* B0_DIAGNOSTIC_LEGACY_SN
|
|
|
2124
2437
|
mod tests {
|
|
2125
2438
|
use super::*;
|
|
2126
2439
|
|
|
2440
|
+
fn observed_pane() -> PaneInfo {
|
|
2441
|
+
PaneInfo {
|
|
2442
|
+
pane_id: PaneId::new("%7"),
|
|
2443
|
+
session: crate::transport::SessionName::new("new-session"),
|
|
2444
|
+
window_index: Some(3),
|
|
2445
|
+
window_name: Some(crate::transport::WindowName::new("new-window")),
|
|
2446
|
+
pane_index: Some(1),
|
|
2447
|
+
tty: Some("/dev/ttys007".to_string()),
|
|
2448
|
+
current_command: Some("codex".to_string()),
|
|
2449
|
+
current_path: None,
|
|
2450
|
+
active: true,
|
|
2451
|
+
pane_pid: Some(700),
|
|
2452
|
+
leader_env: std::collections::BTreeMap::new(),
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2127
2456
|
#[test]
|
|
2128
2457
|
fn attribute_command_provider_recognizes_copilot() {
|
|
2129
2458
|
assert_eq!(
|
|
@@ -2135,4 +2464,128 @@ mod tests {
|
|
|
2135
2464
|
Some(Provider::Copilot)
|
|
2136
2465
|
);
|
|
2137
2466
|
}
|
|
2467
|
+
|
|
2468
|
+
#[test]
|
|
2469
|
+
fn already_bound_refresh_updates_observations_without_reclaiming() {
|
|
2470
|
+
let mut state = json!({
|
|
2471
|
+
"leader_receiver": {
|
|
2472
|
+
"status": "attached",
|
|
2473
|
+
"pane_id": "%7",
|
|
2474
|
+
"owner_epoch": 10,
|
|
2475
|
+
"owner_agent_id": "leader",
|
|
2476
|
+
"session_name": "old-session",
|
|
2477
|
+
"pane_tty": "/dev/old",
|
|
2478
|
+
"attached_at": "2026-01-01T00:00:00Z"
|
|
2479
|
+
},
|
|
2480
|
+
"teams": {"team-a": {"leader_receiver": {
|
|
2481
|
+
"status": "attached",
|
|
2482
|
+
"pane_id": "%7",
|
|
2483
|
+
"owner_epoch": 10,
|
|
2484
|
+
"owner_agent_id": "leader",
|
|
2485
|
+
"session_name": "old-session",
|
|
2486
|
+
"pane_tty": "/dev/old",
|
|
2487
|
+
"attached_at": "2026-01-01T00:00:00Z"
|
|
2488
|
+
}}}
|
|
2489
|
+
});
|
|
2490
|
+
|
|
2491
|
+
assert!(refresh_already_bound_receiver_observation(
|
|
2492
|
+
&mut state,
|
|
2493
|
+
&observed_pane(),
|
|
2494
|
+
Some("/tmp/team-agent-live.sock"),
|
|
2495
|
+
Some("team-a"),
|
|
2496
|
+
None,
|
|
2497
|
+
));
|
|
2498
|
+
let receiver = &state["leader_receiver"];
|
|
2499
|
+
assert_eq!(receiver["owner_epoch"], json!(10));
|
|
2500
|
+
assert_eq!(receiver["owner_agent_id"], json!("leader"));
|
|
2501
|
+
assert_eq!(receiver["session_name"], json!("new-session"));
|
|
2502
|
+
assert_eq!(receiver["window_name"], json!("new-window"));
|
|
2503
|
+
assert_eq!(receiver["pane_tty"], json!("/dev/ttys007"));
|
|
2504
|
+
assert_eq!(receiver["tmux_socket"], json!("/tmp/team-agent-live.sock"));
|
|
2505
|
+
assert_ne!(receiver["attached_at"], json!("2026-01-01T00:00:00Z"));
|
|
2506
|
+
assert_eq!(
|
|
2507
|
+
state["teams"]["team-a"]["leader_receiver"]["session_name"],
|
|
2508
|
+
json!("new-session")
|
|
2509
|
+
);
|
|
2510
|
+
assert_eq!(
|
|
2511
|
+
state["teams"]["team-a"]["leader_receiver"]["owner_epoch"],
|
|
2512
|
+
json!(10)
|
|
2513
|
+
);
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
#[test]
|
|
2517
|
+
fn already_bound_explicit_claim_persists_cross_workspace_authority() {
|
|
2518
|
+
let mut state = json!({
|
|
2519
|
+
"leader_receiver": {"status": "attached", "pane_id": "%7"},
|
|
2520
|
+
"teams": {"team-a": {"leader_receiver": {
|
|
2521
|
+
"status": "attached", "pane_id": "%7"
|
|
2522
|
+
}}}
|
|
2523
|
+
});
|
|
2524
|
+
let target = LeaderClaimTarget {
|
|
2525
|
+
provider: Provider::Codex,
|
|
2526
|
+
leader_session_uuid: None,
|
|
2527
|
+
team_id: Some("team-a".to_string()),
|
|
2528
|
+
pane_info: Some(observed_pane()),
|
|
2529
|
+
endpoint: Some("/tmp/team-agent-live.sock".to_string()),
|
|
2530
|
+
scope_authority: Some("explicit_claim".to_string()),
|
|
2531
|
+
authorized_team_workspace: Some(PathBuf::from("/tmp/workspace-a")),
|
|
2532
|
+
binding_nonce: Some("nonce-7".to_string()),
|
|
2533
|
+
};
|
|
2534
|
+
|
|
2535
|
+
assert!(refresh_already_bound_receiver_observation(
|
|
2536
|
+
&mut state,
|
|
2537
|
+
&observed_pane(),
|
|
2538
|
+
Some("/tmp/team-agent-live.sock"),
|
|
2539
|
+
Some("team-a"),
|
|
2540
|
+
Some(&target),
|
|
2541
|
+
));
|
|
2542
|
+
for receiver in [
|
|
2543
|
+
&state["leader_receiver"],
|
|
2544
|
+
&state["teams"]["team-a"]["leader_receiver"],
|
|
2545
|
+
] {
|
|
2546
|
+
assert_eq!(receiver["scope_authority"], json!("explicit_claim"));
|
|
2547
|
+
assert_eq!(
|
|
2548
|
+
receiver["authorized_team_workspace"],
|
|
2549
|
+
json!("/tmp/workspace-a")
|
|
2550
|
+
);
|
|
2551
|
+
assert_eq!(receiver["binding_nonce"], json!("nonce-7"));
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
#[test]
|
|
2556
|
+
fn pane_binding_nonce_rotates_per_explicit_claim() {
|
|
2557
|
+
let workspace = Path::new("/tmp/workspace-a");
|
|
2558
|
+
let endpoint = "/tmp/team-agent-live.sock";
|
|
2559
|
+
let pane = PaneId::new("%7");
|
|
2560
|
+
let first = next_pane_binding_nonce(workspace, endpoint, &pane);
|
|
2561
|
+
let second = next_pane_binding_nonce(workspace, endpoint, &pane);
|
|
2562
|
+
assert_eq!(first.len(), 32);
|
|
2563
|
+
assert!(first.chars().all(|ch| ch.is_ascii_hexdigit()));
|
|
2564
|
+
assert_ne!(first, second);
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
#[test]
|
|
2568
|
+
fn claim_authority_preserves_recorded_absolute_observation() {
|
|
2569
|
+
let workspace = Path::new("/tmp/workspace-a");
|
|
2570
|
+
let absolute = "/tmp/tmux-501/ta-live";
|
|
2571
|
+
let state = json!({
|
|
2572
|
+
"leader_receiver": {"pane_id": "%9", "tmux_socket": absolute}
|
|
2573
|
+
});
|
|
2574
|
+
let pane = observed_pane();
|
|
2575
|
+
let targets = vec![
|
|
2576
|
+
ClaimLeaderTargetCandidate {
|
|
2577
|
+
info: pane.clone(),
|
|
2578
|
+
endpoint: Some(crate::tmux_backend::socket_name_for_workspace(workspace)),
|
|
2579
|
+
},
|
|
2580
|
+
ClaimLeaderTargetCandidate {
|
|
2581
|
+
info: pane,
|
|
2582
|
+
endpoint: Some(absolute.to_string()),
|
|
2583
|
+
},
|
|
2584
|
+
];
|
|
2585
|
+
|
|
2586
|
+
assert_eq!(
|
|
2587
|
+
authoritative_observed_endpoint(workspace, &state, &targets, &targets[0]).as_deref(),
|
|
2588
|
+
Some(absolute)
|
|
2589
|
+
);
|
|
2590
|
+
}
|
|
2138
2591
|
}
|